@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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
2
|
import { Hono } from 'hono'
|
|
3
|
-
import { requireRole, requirePermission } from '@server/guards'
|
|
3
|
+
import { requireRole, requirePermission } from '@server/auth/guards'
|
|
4
4
|
import type { HonoEnv } from '@server/types'
|
|
5
5
|
|
|
6
6
|
describe('guards', () => {
|
|
@@ -371,4 +371,277 @@ describe('jwtAuth', () => {
|
|
|
371
371
|
isSuperAdmin: false,
|
|
372
372
|
})
|
|
373
373
|
})
|
|
374
|
+
|
|
375
|
+
it('handles snake_case column names from DB', async () => {
|
|
376
|
+
vi.mocked(verify).mockResolvedValue({
|
|
377
|
+
sub: 'user-123',
|
|
378
|
+
email: 'test@example.com',
|
|
379
|
+
iat: Date.now(),
|
|
380
|
+
exp: Date.now() + 3600000,
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
// Use snake_case column names as returned by SQLite
|
|
384
|
+
setMockQueryResult(mockDb, ['user-123'], {
|
|
385
|
+
id: 'user-123',
|
|
386
|
+
email: 'test@example.com',
|
|
387
|
+
name: 'Test User',
|
|
388
|
+
status: 'active',
|
|
389
|
+
provider_ids: JSON.stringify(['google']),
|
|
390
|
+
is_super_admin: 1,
|
|
391
|
+
created_at: new Date().toISOString(),
|
|
392
|
+
updated_at: new Date().toISOString(),
|
|
393
|
+
deleted_at: null,
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
const app = createApp()
|
|
397
|
+
const res = await app.request('/test', {
|
|
398
|
+
headers: { Authorization: 'Bearer valid-token' },
|
|
399
|
+
})
|
|
400
|
+
const body = await res.json()
|
|
401
|
+
|
|
402
|
+
expect(res.status).toBe(200)
|
|
403
|
+
expect(body.user.providerIds).toEqual(['google'])
|
|
404
|
+
expect(body.user.isSuperAdmin).toBe(true)
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
it('handles providerIds as JSON string', async () => {
|
|
408
|
+
vi.mocked(verify).mockResolvedValue({
|
|
409
|
+
sub: 'user-123',
|
|
410
|
+
email: 'test@example.com',
|
|
411
|
+
iat: Date.now(),
|
|
412
|
+
exp: Date.now() + 3600000,
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
setMockQueryResult(mockDb, ['user-123'], {
|
|
416
|
+
id: 'user-123',
|
|
417
|
+
email: 'test@example.com',
|
|
418
|
+
name: 'Test User',
|
|
419
|
+
status: 'active',
|
|
420
|
+
providerIds: '["google", "github"]',
|
|
421
|
+
isSuperAdmin: 0,
|
|
422
|
+
createdAt: new Date().toISOString(),
|
|
423
|
+
updatedAt: new Date().toISOString(),
|
|
424
|
+
deletedAt: null,
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
const app = createApp()
|
|
428
|
+
const res = await app.request('/test', {
|
|
429
|
+
headers: { Authorization: 'Bearer valid-token' },
|
|
430
|
+
})
|
|
431
|
+
const body = await res.json()
|
|
432
|
+
|
|
433
|
+
expect(res.status).toBe(200)
|
|
434
|
+
expect(body.user.providerIds).toEqual(['google', 'github'])
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
it('handles invalid JSON in providerIds', async () => {
|
|
438
|
+
vi.mocked(verify).mockResolvedValue({
|
|
439
|
+
sub: 'user-123',
|
|
440
|
+
email: 'test@example.com',
|
|
441
|
+
iat: Date.now(),
|
|
442
|
+
exp: Date.now() + 3600000,
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
setMockQueryResult(mockDb, ['user-123'], {
|
|
446
|
+
id: 'user-123',
|
|
447
|
+
email: 'test@example.com',
|
|
448
|
+
name: 'Test User',
|
|
449
|
+
status: 'active',
|
|
450
|
+
providerIds: 'invalid-json',
|
|
451
|
+
isSuperAdmin: 0,
|
|
452
|
+
createdAt: new Date().toISOString(),
|
|
453
|
+
updatedAt: new Date().toISOString(),
|
|
454
|
+
deletedAt: null,
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
const app = createApp()
|
|
458
|
+
const res = await app.request('/test', {
|
|
459
|
+
headers: { Authorization: 'Bearer valid-token' },
|
|
460
|
+
})
|
|
461
|
+
const body = await res.json()
|
|
462
|
+
|
|
463
|
+
expect(res.status).toBe(200)
|
|
464
|
+
expect(body.user.providerIds).toEqual([])
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
it('handles non-array JSON in providerIds', async () => {
|
|
468
|
+
vi.mocked(verify).mockResolvedValue({
|
|
469
|
+
sub: 'user-123',
|
|
470
|
+
email: 'test@example.com',
|
|
471
|
+
iat: Date.now(),
|
|
472
|
+
exp: Date.now() + 3600000,
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
setMockQueryResult(mockDb, ['user-123'], {
|
|
476
|
+
id: 'user-123',
|
|
477
|
+
email: 'test@example.com',
|
|
478
|
+
name: 'Test User',
|
|
479
|
+
status: 'active',
|
|
480
|
+
providerIds: '{"key": "value"}',
|
|
481
|
+
isSuperAdmin: 0,
|
|
482
|
+
createdAt: new Date().toISOString(),
|
|
483
|
+
updatedAt: new Date().toISOString(),
|
|
484
|
+
deletedAt: null,
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
const app = createApp()
|
|
488
|
+
const res = await app.request('/test', {
|
|
489
|
+
headers: { Authorization: 'Bearer valid-token' },
|
|
490
|
+
})
|
|
491
|
+
const body = await res.json()
|
|
492
|
+
|
|
493
|
+
expect(res.status).toBe(200)
|
|
494
|
+
expect(body.user.providerIds).toEqual([])
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
it('handles null providerIds', async () => {
|
|
498
|
+
vi.mocked(verify).mockResolvedValue({
|
|
499
|
+
sub: 'user-123',
|
|
500
|
+
email: 'test@example.com',
|
|
501
|
+
iat: Date.now(),
|
|
502
|
+
exp: Date.now() + 3600000,
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
setMockQueryResult(mockDb, ['user-123'], {
|
|
506
|
+
id: 'user-123',
|
|
507
|
+
email: 'test@example.com',
|
|
508
|
+
name: 'Test User',
|
|
509
|
+
status: 'active',
|
|
510
|
+
providerIds: null,
|
|
511
|
+
isSuperAdmin: 0,
|
|
512
|
+
createdAt: new Date().toISOString(),
|
|
513
|
+
updatedAt: new Date().toISOString(),
|
|
514
|
+
deletedAt: null,
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
const app = createApp()
|
|
518
|
+
const res = await app.request('/test', {
|
|
519
|
+
headers: { Authorization: 'Bearer valid-token' },
|
|
520
|
+
})
|
|
521
|
+
const body = await res.json()
|
|
522
|
+
|
|
523
|
+
expect(res.status).toBe(200)
|
|
524
|
+
expect(body.user.providerIds).toEqual([])
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
it('handles isSuperAdmin as boolean true', async () => {
|
|
528
|
+
vi.mocked(verify).mockResolvedValue({
|
|
529
|
+
sub: 'user-123',
|
|
530
|
+
email: 'test@example.com',
|
|
531
|
+
iat: Date.now(),
|
|
532
|
+
exp: Date.now() + 3600000,
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
setMockQueryResult(mockDb, ['user-123'], {
|
|
536
|
+
id: 'user-123',
|
|
537
|
+
email: 'test@example.com',
|
|
538
|
+
name: 'Test User',
|
|
539
|
+
status: 'active',
|
|
540
|
+
providerIds: [],
|
|
541
|
+
isSuperAdmin: true,
|
|
542
|
+
createdAt: new Date().toISOString(),
|
|
543
|
+
updatedAt: new Date().toISOString(),
|
|
544
|
+
deletedAt: null,
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
const app = createApp()
|
|
548
|
+
const res = await app.request('/test', {
|
|
549
|
+
headers: { Authorization: 'Bearer valid-token' },
|
|
550
|
+
})
|
|
551
|
+
const body = await res.json()
|
|
552
|
+
|
|
553
|
+
expect(res.status).toBe(200)
|
|
554
|
+
expect(body.user.isSuperAdmin).toBe(true)
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
it('handles isSuperAdmin as string "1"', async () => {
|
|
558
|
+
vi.mocked(verify).mockResolvedValue({
|
|
559
|
+
sub: 'user-123',
|
|
560
|
+
email: 'test@example.com',
|
|
561
|
+
iat: Date.now(),
|
|
562
|
+
exp: Date.now() + 3600000,
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
setMockQueryResult(mockDb, ['user-123'], {
|
|
566
|
+
id: 'user-123',
|
|
567
|
+
email: 'test@example.com',
|
|
568
|
+
name: 'Test User',
|
|
569
|
+
status: 'active',
|
|
570
|
+
providerIds: [],
|
|
571
|
+
isSuperAdmin: '1',
|
|
572
|
+
createdAt: new Date().toISOString(),
|
|
573
|
+
updatedAt: new Date().toISOString(),
|
|
574
|
+
deletedAt: null,
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
const app = createApp()
|
|
578
|
+
const res = await app.request('/test', {
|
|
579
|
+
headers: { Authorization: 'Bearer valid-token' },
|
|
580
|
+
})
|
|
581
|
+
const body = await res.json()
|
|
582
|
+
|
|
583
|
+
expect(res.status).toBe(200)
|
|
584
|
+
expect(body.user.isSuperAdmin).toBe(true)
|
|
585
|
+
})
|
|
586
|
+
|
|
587
|
+
it('handles isSuperAdmin as string "true"', async () => {
|
|
588
|
+
vi.mocked(verify).mockResolvedValue({
|
|
589
|
+
sub: 'user-123',
|
|
590
|
+
email: 'test@example.com',
|
|
591
|
+
iat: Date.now(),
|
|
592
|
+
exp: Date.now() + 3600000,
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
setMockQueryResult(mockDb, ['user-123'], {
|
|
596
|
+
id: 'user-123',
|
|
597
|
+
email: 'test@example.com',
|
|
598
|
+
name: 'Test User',
|
|
599
|
+
status: 'active',
|
|
600
|
+
providerIds: [],
|
|
601
|
+
isSuperAdmin: 'true',
|
|
602
|
+
createdAt: new Date().toISOString(),
|
|
603
|
+
updatedAt: new Date().toISOString(),
|
|
604
|
+
deletedAt: null,
|
|
605
|
+
})
|
|
606
|
+
|
|
607
|
+
const app = createApp()
|
|
608
|
+
const res = await app.request('/test', {
|
|
609
|
+
headers: { Authorization: 'Bearer valid-token' },
|
|
610
|
+
})
|
|
611
|
+
const body = await res.json()
|
|
612
|
+
|
|
613
|
+
expect(res.status).toBe(200)
|
|
614
|
+
expect(body.user.isSuperAdmin).toBe(true)
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
it('handles deletedAt with value', async () => {
|
|
618
|
+
vi.mocked(verify).mockResolvedValue({
|
|
619
|
+
sub: 'user-123',
|
|
620
|
+
email: 'test@example.com',
|
|
621
|
+
iat: Date.now(),
|
|
622
|
+
exp: Date.now() + 3600000,
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
const deletedDate = new Date().toISOString()
|
|
626
|
+
setMockQueryResult(mockDb, ['user-123'], {
|
|
627
|
+
id: 'user-123',
|
|
628
|
+
email: 'test@example.com',
|
|
629
|
+
name: 'Test User',
|
|
630
|
+
status: 'active',
|
|
631
|
+
providerIds: [],
|
|
632
|
+
isSuperAdmin: 0,
|
|
633
|
+
createdAt: new Date().toISOString(),
|
|
634
|
+
updatedAt: new Date().toISOString(),
|
|
635
|
+
deleted_at: deletedDate,
|
|
636
|
+
})
|
|
637
|
+
|
|
638
|
+
const app = createApp()
|
|
639
|
+
const res = await app.request('/test', {
|
|
640
|
+
headers: { Authorization: 'Bearer valid-token' },
|
|
641
|
+
})
|
|
642
|
+
const body = await res.json()
|
|
643
|
+
|
|
644
|
+
expect(res.status).toBe(200)
|
|
645
|
+
expect(body.user.deletedAt).toBe(deletedDate)
|
|
646
|
+
})
|
|
374
647
|
})
|
|
@@ -167,6 +167,20 @@ describe('Auth Routes', () => {
|
|
|
167
167
|
expect(body).toContain('Invalid state parameter')
|
|
168
168
|
})
|
|
169
169
|
|
|
170
|
+
it('returns 400 when oauth_state cookie is invalid JSON', async () => {
|
|
171
|
+
const app = createApp()
|
|
172
|
+
const res = await app.request('/auth/callback?code=test-code&state=test-state', {
|
|
173
|
+
method: 'GET',
|
|
174
|
+
headers: {
|
|
175
|
+
Cookie: 'oauth_state=invalid-json-string',
|
|
176
|
+
},
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
expect(res.status).toBe(400)
|
|
180
|
+
const body = await res.text()
|
|
181
|
+
expect(body).toContain('Invalid OAuth state cookie')
|
|
182
|
+
})
|
|
183
|
+
|
|
170
184
|
it('creates session on successful callback', async () => {
|
|
171
185
|
const testUser = createUserFixture({ id: 'user-123', email: 'test@example.com' })
|
|
172
186
|
|
|
@@ -207,6 +221,103 @@ describe('Auth Routes', () => {
|
|
|
207
221
|
expect(res.status).toBe(302)
|
|
208
222
|
expect(res.headers.get('Location')).toContain('/dashboard')
|
|
209
223
|
})
|
|
224
|
+
|
|
225
|
+
it('accepts pending invitation during callback', async () => {
|
|
226
|
+
const testUser = createUserFixture({ id: 'user-123', email: 'test@example.com' })
|
|
227
|
+
|
|
228
|
+
vi.mocked(exchangeCodeForTokens).mockResolvedValue({
|
|
229
|
+
access_token: 'test-access-token',
|
|
230
|
+
id_token: 'test-id-token',
|
|
231
|
+
refresh_token: 'test-refresh-token',
|
|
232
|
+
expires_in: 3600,
|
|
233
|
+
token_type: 'Bearer',
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
vi.mocked(decodeIdToken).mockReturnValue({
|
|
237
|
+
sub: 'google-123',
|
|
238
|
+
email: 'test@example.com',
|
|
239
|
+
name: 'Test User',
|
|
240
|
+
picture: 'https://example.com/avatar.jpg',
|
|
241
|
+
email_verified: true,
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
vi.mocked(authService.findOrCreateUser).mockResolvedValue({
|
|
245
|
+
user: testUser,
|
|
246
|
+
isNew: false,
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
vi.mocked(invitationsService.getByToken).mockResolvedValue({
|
|
250
|
+
id: 'invite-123',
|
|
251
|
+
token: 'valid-invite-token',
|
|
252
|
+
email: 'test@example.com',
|
|
253
|
+
role: 'VIEWER',
|
|
254
|
+
accountId: 'account-1',
|
|
255
|
+
status: 'pending',
|
|
256
|
+
expiresAt: new Date(Date.now() + 86400000).toISOString(),
|
|
257
|
+
createdAt: new Date().toISOString(),
|
|
258
|
+
updatedAt: new Date().toISOString(),
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
vi.mocked(invitationsService.accept).mockResolvedValue()
|
|
262
|
+
|
|
263
|
+
const app = createApp()
|
|
264
|
+
const res = await app.request('/auth/callback?code=test-code&state=test-state', {
|
|
265
|
+
method: 'GET',
|
|
266
|
+
headers: {
|
|
267
|
+
Cookie: 'oauth_state=' + encodeURIComponent(JSON.stringify({
|
|
268
|
+
codeVerifier: 'test-verifier',
|
|
269
|
+
state: 'test-state',
|
|
270
|
+
redirect: null,
|
|
271
|
+
})) + '; pending_invitation=valid-invite-token',
|
|
272
|
+
},
|
|
273
|
+
redirect: 'manual',
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
expect(res.status).toBe(302)
|
|
277
|
+
expect(invitationsService.getByToken).toHaveBeenCalledWith(expect.anything(), 'valid-invite-token')
|
|
278
|
+
expect(invitationsService.accept).toHaveBeenCalled()
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('uses custom redirect from oauth state', async () => {
|
|
282
|
+
const testUser = createUserFixture({ id: 'user-123', email: 'test@example.com' })
|
|
283
|
+
|
|
284
|
+
vi.mocked(exchangeCodeForTokens).mockResolvedValue({
|
|
285
|
+
access_token: 'test-access-token',
|
|
286
|
+
id_token: 'test-id-token',
|
|
287
|
+
refresh_token: 'test-refresh-token',
|
|
288
|
+
expires_in: 3600,
|
|
289
|
+
token_type: 'Bearer',
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
vi.mocked(decodeIdToken).mockReturnValue({
|
|
293
|
+
sub: 'google-123',
|
|
294
|
+
email: 'test@example.com',
|
|
295
|
+
name: 'Test User',
|
|
296
|
+
picture: 'https://example.com/avatar.jpg',
|
|
297
|
+
email_verified: true,
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
vi.mocked(authService.findOrCreateUser).mockResolvedValue({
|
|
301
|
+
user: testUser,
|
|
302
|
+
isNew: false,
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
const app = createApp()
|
|
306
|
+
const res = await app.request('/auth/callback?code=test-code&state=test-state', {
|
|
307
|
+
method: 'GET',
|
|
308
|
+
headers: {
|
|
309
|
+
Cookie: 'oauth_state=' + encodeURIComponent(JSON.stringify({
|
|
310
|
+
codeVerifier: 'test-verifier',
|
|
311
|
+
state: 'test-state',
|
|
312
|
+
redirect: '/settings',
|
|
313
|
+
})),
|
|
314
|
+
},
|
|
315
|
+
redirect: 'manual',
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
expect(res.status).toBe(302)
|
|
319
|
+
expect(res.headers.get('Location')).toContain('/settings')
|
|
320
|
+
})
|
|
210
321
|
})
|
|
211
322
|
|
|
212
323
|
describe('POST /auth/logout', () => {
|
|
@@ -7,9 +7,9 @@ import { createMockEnv } from '@tests/helpers/server'
|
|
|
7
7
|
import {
|
|
8
8
|
createUserFixture,
|
|
9
9
|
createAccountFixture,
|
|
10
|
-
createDeletedUserFixture,
|
|
11
10
|
} from '@tests/fixtures/server'
|
|
12
11
|
import { NotFoundError } from '@server/lib/errors'
|
|
12
|
+
import type { Role } from '@server/auth/roles'
|
|
13
13
|
|
|
14
14
|
// Test UUIDs
|
|
15
15
|
const TEST_USER_ID = '550e8400-e29b-41d4-a716-446655440001'
|
|
@@ -33,7 +33,6 @@ vi.mock('@server/services', () => ({
|
|
|
33
33
|
import { usersService } from '@server/services'
|
|
34
34
|
|
|
35
35
|
describe('Users Routes', () => {
|
|
36
|
-
let app: Hono<HonoEnv>
|
|
37
36
|
let mockEnv: ReturnType<typeof createMockEnv>
|
|
38
37
|
let mockDb: any
|
|
39
38
|
let testUser: ReturnType<typeof createUserFixture>
|
|
@@ -60,12 +59,10 @@ describe('Users Routes', () => {
|
|
|
60
59
|
delete: vi.fn().mockReturnThis(),
|
|
61
60
|
execute: vi.fn().mockResolvedValue({ rows: [] }),
|
|
62
61
|
}
|
|
63
|
-
|
|
64
|
-
app = new Hono<HonoEnv>()
|
|
65
62
|
})
|
|
66
63
|
|
|
67
64
|
// Helper to setup authenticated app with specific role
|
|
68
|
-
function setupAuthenticatedApp(userRole = 'VIEWER', isSuperAdmin = false) {
|
|
65
|
+
function setupAuthenticatedApp(userRole: Role = 'VIEWER', isSuperAdmin = false) {
|
|
69
66
|
const authenticatedApp = new Hono<HonoEnv>()
|
|
70
67
|
|
|
71
68
|
authenticatedApp.use('*', async (c, next) => {
|
|
@@ -523,4 +520,71 @@ describe('Users Routes', () => {
|
|
|
523
520
|
// with body may not work correctly in the Hono test environment.
|
|
524
521
|
// This endpoint is tested via E2E tests instead.
|
|
525
522
|
// Role-based access control is tested via the requireRole middleware.
|
|
523
|
+
|
|
524
|
+
describe('Missing context error handling', () => {
|
|
525
|
+
it('GET /users/:id throws when context is missing', async () => {
|
|
526
|
+
const unauthApp = setupUnauthenticatedApp()
|
|
527
|
+
|
|
528
|
+
const res = await unauthApp.request(`/users/${TEST_USER_ID}`, {
|
|
529
|
+
method: 'GET',
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
expect(res.status).toBe(500)
|
|
533
|
+
expect(usersService.findById).not.toHaveBeenCalled()
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
it('PATCH /users/:id returns 401 when not authenticated', async () => {
|
|
537
|
+
const unauthApp = setupUnauthenticatedApp()
|
|
538
|
+
|
|
539
|
+
const res = await unauthApp.request(`/users/${TEST_USER_ID}`, {
|
|
540
|
+
method: 'PATCH',
|
|
541
|
+
headers: {
|
|
542
|
+
'Content-Type': 'application/json',
|
|
543
|
+
},
|
|
544
|
+
body: JSON.stringify({ name: 'Updated' }),
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
expect(res.status).toBe(401)
|
|
548
|
+
expect(usersService.update).not.toHaveBeenCalled()
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
it('DELETE /users/:id returns 401 when not authenticated', async () => {
|
|
552
|
+
const unauthApp = setupUnauthenticatedApp()
|
|
553
|
+
|
|
554
|
+
const res = await unauthApp.request(`/users/${TEST_USER_ID}`, {
|
|
555
|
+
method: 'DELETE',
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
expect(res.status).toBe(401)
|
|
559
|
+
expect(usersService.delete).not.toHaveBeenCalled()
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
it('POST /users/:id/restore returns 401 when not authenticated', async () => {
|
|
563
|
+
const unauthApp = setupUnauthenticatedApp()
|
|
564
|
+
|
|
565
|
+
const res = await unauthApp.request(`/users/${TEST_USER_ID}/restore`, {
|
|
566
|
+
method: 'POST',
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
expect(res.status).toBe(401)
|
|
570
|
+
expect(usersService.restore).not.toHaveBeenCalled()
|
|
571
|
+
})
|
|
572
|
+
|
|
573
|
+
it('POST /users/accounts returns 401 when not authenticated', async () => {
|
|
574
|
+
const unauthApp = setupUnauthenticatedApp()
|
|
575
|
+
|
|
576
|
+
const res = await unauthApp.request('/users/accounts', {
|
|
577
|
+
method: 'POST',
|
|
578
|
+
headers: {
|
|
579
|
+
'Content-Type': 'application/json',
|
|
580
|
+
},
|
|
581
|
+
body: JSON.stringify([
|
|
582
|
+
{ userId: TEST_USER_ID, accountId: TEST_ACCOUNT_ID, role: 'VIEWER' },
|
|
583
|
+
]),
|
|
584
|
+
})
|
|
585
|
+
|
|
586
|
+
expect(res.status).toBe(401)
|
|
587
|
+
expect(usersService.createUserAccounts).not.toHaveBeenCalled()
|
|
588
|
+
})
|
|
589
|
+
})
|
|
526
590
|
})
|
|
@@ -20,6 +20,17 @@ vi.mock('@server/db/sql', () => ({
|
|
|
20
20
|
queryOne: vi.fn(),
|
|
21
21
|
queryAll: vi.fn(),
|
|
22
22
|
execute: vi.fn(),
|
|
23
|
+
toStringValue: (value: unknown) => {
|
|
24
|
+
if (typeof value === 'string') return value
|
|
25
|
+
if (typeof value === 'number' || typeof value === 'bigint') return String(value)
|
|
26
|
+
return ''
|
|
27
|
+
},
|
|
28
|
+
toNullableString: (value: unknown) => {
|
|
29
|
+
if (value === null || value === undefined) return null
|
|
30
|
+
if (typeof value === 'string') return value
|
|
31
|
+
if (typeof value === 'number' || typeof value === 'bigint') return String(value)
|
|
32
|
+
return null
|
|
33
|
+
},
|
|
23
34
|
}))
|
|
24
35
|
|
|
25
36
|
import { auditedInsert, auditedUpdate, auditedDelete } from '@server/lib/audited-db'
|
|
@@ -104,6 +115,26 @@ describe('accountsService', () => {
|
|
|
104
115
|
|
|
105
116
|
expect((queryOne as Mock).mock.calls[0][2]).toContain(ctx.user.id)
|
|
106
117
|
})
|
|
118
|
+
|
|
119
|
+
it('should filter by query when provided', async () => {
|
|
120
|
+
;(queryOne as Mock).mockResolvedValueOnce({ count: 1 })
|
|
121
|
+
;(queryAll as Mock).mockResolvedValueOnce([])
|
|
122
|
+
|
|
123
|
+
await accountsService.findAll(db, superAdminCtx, { ...defaultPagination, query: 'test' })
|
|
124
|
+
|
|
125
|
+
// Query should include LIKE clauses for name and domain
|
|
126
|
+
expect((queryOne as Mock).mock.calls[0][2]).toContain('%test%')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('should handle null count result', async () => {
|
|
130
|
+
;(queryOne as Mock).mockResolvedValueOnce(null)
|
|
131
|
+
;(queryAll as Mock).mockResolvedValueOnce([])
|
|
132
|
+
|
|
133
|
+
const result = await accountsService.findAll(db, superAdminCtx, defaultPagination)
|
|
134
|
+
|
|
135
|
+
expect(result.data).toHaveLength(0)
|
|
136
|
+
expect(result.meta.totalItems).toBe(0)
|
|
137
|
+
})
|
|
107
138
|
})
|
|
108
139
|
|
|
109
140
|
describe('findById', () => {
|
|
@@ -140,6 +171,22 @@ describe('accountsService', () => {
|
|
|
140
171
|
|
|
141
172
|
await expect(accountsService.findById(db, ctx, account.id)).rejects.toThrow(NotFoundError)
|
|
142
173
|
})
|
|
174
|
+
|
|
175
|
+
it('should allow non-super-admin with valid membership', async () => {
|
|
176
|
+
const account = createAccountFixture({ id: 'account-1' })
|
|
177
|
+
;(queryOne as Mock)
|
|
178
|
+
.mockResolvedValueOnce({
|
|
179
|
+
...account,
|
|
180
|
+
created_at: account.createdAt,
|
|
181
|
+
updated_at: account.updatedAt,
|
|
182
|
+
deleted_at: account.deletedAt,
|
|
183
|
+
})
|
|
184
|
+
.mockResolvedValueOnce({ ok: 1 })
|
|
185
|
+
|
|
186
|
+
const result = await accountsService.findById(db, ctx, account.id)
|
|
187
|
+
|
|
188
|
+
expect(result.id).toBe(account.id)
|
|
189
|
+
})
|
|
143
190
|
})
|
|
144
191
|
|
|
145
192
|
describe('create', () => {
|
|
@@ -166,6 +213,27 @@ describe('accountsService', () => {
|
|
|
166
213
|
expect(result.name).toBe('New Account')
|
|
167
214
|
expect(auditedInsert).toHaveBeenCalled()
|
|
168
215
|
})
|
|
216
|
+
|
|
217
|
+
it('should create account with domain when no conflict', async () => {
|
|
218
|
+
;(queryOne as Mock).mockResolvedValueOnce(null)
|
|
219
|
+
|
|
220
|
+
const account = createAccountFixture({ id: 'account-1', name: 'New Account', domain: 'unique.com' })
|
|
221
|
+
;(auditedInsert as Mock).mockResolvedValueOnce([
|
|
222
|
+
{ ...account, created_at: account.createdAt, updated_at: account.updatedAt, deleted_at: account.deletedAt },
|
|
223
|
+
])
|
|
224
|
+
|
|
225
|
+
const result = await accountsService.create(db, superAdminCtx, { name: 'New Account', domain: 'unique.com' })
|
|
226
|
+
|
|
227
|
+
expect(result.domain).toBe('unique.com')
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('should throw when auditedInsert returns empty array', async () => {
|
|
231
|
+
;(auditedInsert as Mock).mockResolvedValueOnce([])
|
|
232
|
+
|
|
233
|
+
await expect(
|
|
234
|
+
accountsService.create(db, superAdminCtx, { name: 'New Account' })
|
|
235
|
+
).rejects.toThrow('Failed to create account')
|
|
236
|
+
})
|
|
169
237
|
})
|
|
170
238
|
|
|
171
239
|
describe('update', () => {
|
|
@@ -209,6 +277,67 @@ describe('accountsService', () => {
|
|
|
209
277
|
expect(result.name).toBe('Updated')
|
|
210
278
|
expect(auditedUpdate).toHaveBeenCalled()
|
|
211
279
|
})
|
|
280
|
+
|
|
281
|
+
it('should update with domain when no conflict', async () => {
|
|
282
|
+
const account = createAccountFixture({ id: 'account-1' })
|
|
283
|
+
;(queryOne as Mock)
|
|
284
|
+
.mockResolvedValueOnce({
|
|
285
|
+
...account,
|
|
286
|
+
created_at: account.createdAt,
|
|
287
|
+
updated_at: account.updatedAt,
|
|
288
|
+
deleted_at: account.deletedAt,
|
|
289
|
+
})
|
|
290
|
+
.mockResolvedValueOnce(null)
|
|
291
|
+
|
|
292
|
+
const updated = { ...account, domain: 'new.com' }
|
|
293
|
+
;(auditedUpdate as Mock).mockResolvedValueOnce([
|
|
294
|
+
{ ...updated, created_at: updated.createdAt, updated_at: updated.updatedAt, deleted_at: updated.deletedAt },
|
|
295
|
+
])
|
|
296
|
+
|
|
297
|
+
const result = await accountsService.update(db, superAdminCtx, account.id, { domain: 'new.com' })
|
|
298
|
+
|
|
299
|
+
expect(result.domain).toBe('new.com')
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('should update description and domain', async () => {
|
|
303
|
+
const account = createAccountFixture({ id: 'account-1' })
|
|
304
|
+
;(queryOne as Mock)
|
|
305
|
+
.mockResolvedValueOnce({
|
|
306
|
+
...account,
|
|
307
|
+
created_at: account.createdAt,
|
|
308
|
+
updated_at: account.updatedAt,
|
|
309
|
+
deleted_at: account.deletedAt,
|
|
310
|
+
})
|
|
311
|
+
.mockResolvedValueOnce(null)
|
|
312
|
+
|
|
313
|
+
const updated = { ...account, description: 'New desc', domain: 'new.com' }
|
|
314
|
+
;(auditedUpdate as Mock).mockResolvedValueOnce([
|
|
315
|
+
{ ...updated, created_at: updated.createdAt, updated_at: updated.updatedAt, deleted_at: updated.deletedAt },
|
|
316
|
+
])
|
|
317
|
+
|
|
318
|
+
const result = await accountsService.update(db, superAdminCtx, account.id, {
|
|
319
|
+
description: 'New desc',
|
|
320
|
+
domain: 'new.com',
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
expect(result.description).toBe('New desc')
|
|
324
|
+
expect(result.domain).toBe('new.com')
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it('should throw when auditedUpdate returns empty array', async () => {
|
|
328
|
+
const account = createAccountFixture({ id: 'account-1' })
|
|
329
|
+
;(queryOne as Mock).mockResolvedValueOnce({
|
|
330
|
+
...account,
|
|
331
|
+
created_at: account.createdAt,
|
|
332
|
+
updated_at: account.updatedAt,
|
|
333
|
+
deleted_at: account.deletedAt,
|
|
334
|
+
})
|
|
335
|
+
;(auditedUpdate as Mock).mockResolvedValueOnce([])
|
|
336
|
+
|
|
337
|
+
await expect(
|
|
338
|
+
accountsService.update(db, superAdminCtx, account.id, { name: 'Updated' })
|
|
339
|
+
).rejects.toThrow('Failed to update account')
|
|
340
|
+
})
|
|
212
341
|
})
|
|
213
342
|
|
|
214
343
|
describe('delete', () => {
|
|
@@ -254,5 +383,24 @@ describe('accountsService', () => {
|
|
|
254
383
|
|
|
255
384
|
expect(result.deletedAt).toBeNull()
|
|
256
385
|
})
|
|
386
|
+
|
|
387
|
+
it('should throw NotFoundError when account not found or not deleted', async () => {
|
|
388
|
+
;(queryOne as Mock).mockResolvedValueOnce(null)
|
|
389
|
+
|
|
390
|
+
await expect(accountsService.restore(db, superAdminCtx, 'not-deleted')).rejects.toThrow(NotFoundError)
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
it('should throw when auditedUpdate returns empty array', async () => {
|
|
394
|
+
const deleted = createDeletedAccountFixture({ id: 'account-1' })
|
|
395
|
+
;(queryOne as Mock).mockResolvedValueOnce({
|
|
396
|
+
...deleted,
|
|
397
|
+
created_at: deleted.createdAt,
|
|
398
|
+
updated_at: deleted.updatedAt,
|
|
399
|
+
deleted_at: deleted.deletedAt,
|
|
400
|
+
})
|
|
401
|
+
;(auditedUpdate as Mock).mockResolvedValueOnce([])
|
|
402
|
+
|
|
403
|
+
await expect(accountsService.restore(db, superAdminCtx, deleted.id)).rejects.toThrow(NotFoundError)
|
|
404
|
+
})
|
|
257
405
|
})
|
|
258
406
|
})
|