@opensaas/stack-auth 0.37.0 → 0.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +60 -0
- package/CLAUDE.md +50 -3
- package/dist/config/adopt-better-auth-tables.d.ts +23 -3
- package/dist/config/adopt-better-auth-tables.d.ts.map +1 -1
- package/dist/config/adopt-better-auth-tables.js +7 -2
- package/dist/config/adopt-better-auth-tables.js.map +1 -1
- package/dist/config/derive-auth-lists.d.ts +6 -1
- package/dist/config/derive-auth-lists.d.ts.map +1 -1
- package/dist/config/derive-auth-lists.js +63 -16
- package/dist/config/derive-auth-lists.js.map +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +12 -5
- package/dist/config/index.js.map +1 -1
- package/dist/config/plugin.d.ts.map +1 -1
- package/dist/config/plugin.js +7 -2
- package/dist/config/plugin.js.map +1 -1
- package/dist/config/types.d.ts +62 -7
- package/dist/config/types.d.ts.map +1 -1
- package/dist/server/build-better-auth-options.test.d.ts +2 -0
- package/dist/server/build-better-auth-options.test.d.ts.map +1 -0
- package/dist/server/build-better-auth-options.test.js +29 -0
- package/dist/server/build-better-auth-options.test.js.map +1 -0
- package/dist/server/get-session-from-auth.test.d.ts +2 -0
- package/dist/server/get-session-from-auth.test.d.ts.map +1 -0
- package/dist/server/get-session-from-auth.test.js +25 -0
- package/dist/server/get-session-from-auth.test.js.map +1 -0
- package/dist/server/index.d.ts +108 -15
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +151 -63
- package/dist/server/index.js.map +1 -1
- package/dist/server/schema-converter.d.ts +15 -6
- package/dist/server/schema-converter.d.ts.map +1 -1
- package/dist/server/schema-converter.js +14 -2
- package/dist/server/schema-converter.js.map +1 -1
- package/package.json +5 -5
- package/src/config/adopt-better-auth-tables.ts +31 -4
- package/src/config/derive-auth-lists.ts +82 -21
- package/src/config/index.ts +18 -5
- package/src/config/plugin.ts +7 -2
- package/src/config/types.ts +63 -9
- package/src/server/build-better-auth-options.test.ts +59 -0
- package/src/server/get-session-from-auth.test.ts +52 -0
- package/src/server/index.ts +273 -41
- package/src/server/schema-converter.ts +29 -8
- package/tests/adopt-better-auth-tables.test.ts +73 -0
- package/tests/config.test.ts +161 -0
- package/tests/derive-auth-lists.test.ts +104 -0
- package/tests/generated-fk-shape.test.ts +81 -0
- package/tests/plugin-schema-placement.test.ts +39 -0
- package/tests/rate-limit-e2e.test.ts +239 -0
- package/tests/schema-converter.test.ts +58 -0
- package/tests/server.test.ts +310 -4
- package/tsconfig.tsbuildinfo +1 -1
- package/vitest.config.ts +7 -1
|
@@ -36,6 +36,47 @@ describe('convertTableToList', () => {
|
|
|
36
36
|
expect(listConfig.fields.score.defaultValue).toBe(0)
|
|
37
37
|
})
|
|
38
38
|
|
|
39
|
+
it('should convert a number field with bigint: true to a bigInt field (issue #917)', () => {
|
|
40
|
+
const tableSchema = {
|
|
41
|
+
modelName: 'TestTable',
|
|
42
|
+
fields: {
|
|
43
|
+
lastRequest: { type: 'number', required: true, bigint: true },
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const listConfig = convertTableToList('test_table', tableSchema)
|
|
48
|
+
|
|
49
|
+
expect(listConfig.fields.lastRequest.type).toBe('bigInt')
|
|
50
|
+
expect(listConfig.fields.lastRequest.validation?.isRequired).toBe(true)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('should keep a number field with bigint: false as integer', () => {
|
|
54
|
+
const tableSchema = {
|
|
55
|
+
modelName: 'TestTable',
|
|
56
|
+
fields: {
|
|
57
|
+
age: { type: 'number', bigint: false },
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const listConfig = convertTableToList('test_table', tableSchema)
|
|
62
|
+
|
|
63
|
+
expect(listConfig.fields.age.type).toBe('integer')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('should pass defaultValue through on a bigint number field', () => {
|
|
67
|
+
const tableSchema = {
|
|
68
|
+
modelName: 'TestTable',
|
|
69
|
+
fields: {
|
|
70
|
+
lastRequest: { type: 'number', bigint: true, defaultValue: 0 },
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const listConfig = convertTableToList('test_table', tableSchema)
|
|
75
|
+
|
|
76
|
+
expect(listConfig.fields.lastRequest.type).toBe('bigInt')
|
|
77
|
+
expect(listConfig.fields.lastRequest.defaultValue).toBe(0)
|
|
78
|
+
})
|
|
79
|
+
|
|
39
80
|
it('should convert boolean fields', () => {
|
|
40
81
|
const tableSchema = {
|
|
41
82
|
modelName: 'TestTable',
|
|
@@ -298,6 +339,23 @@ describe('convertBetterAuthSchema', () => {
|
|
|
298
339
|
expect(lists).not.toHaveProperty('User')
|
|
299
340
|
})
|
|
300
341
|
|
|
342
|
+
it('should resolve a rateLimit table (case-insensitively) against the configured baseModelKeys remap (issue #909)', () => {
|
|
343
|
+
const schema = {
|
|
344
|
+
rateLimit: {
|
|
345
|
+
modelName: '',
|
|
346
|
+
fields: {
|
|
347
|
+
customField: { type: 'boolean' },
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const lists = convertBetterAuthSchema(schema, { rateLimit: 'AuthRateLimit' })
|
|
353
|
+
|
|
354
|
+
expect(lists).toHaveProperty('AuthRateLimit')
|
|
355
|
+
expect(lists).not.toHaveProperty('RateLimit')
|
|
356
|
+
expect(lists.AuthRateLimit.fields).toHaveProperty('customField')
|
|
357
|
+
})
|
|
358
|
+
|
|
301
359
|
it('should leave non-base tables unaffected by baseModelKeys', () => {
|
|
302
360
|
const schema = {
|
|
303
361
|
oauth_application: {
|
package/tests/server.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
2
|
import type { BetterAuthOptions } from 'better-auth'
|
|
3
3
|
import type { NormalizedAuthConfig } from '../src/config/types.js'
|
|
4
4
|
import type { OpenSaasConfig, AccessContext } from '@opensaas/stack-core'
|
|
@@ -459,6 +459,106 @@ describe('betterAuthOptions passthrough', () => {
|
|
|
459
459
|
|
|
460
460
|
expect(config.user).toMatchObject({ modelName: 'CustomUser' })
|
|
461
461
|
})
|
|
462
|
+
|
|
463
|
+
it('rejects betterAuthOptions.rateLimit.storage', async () => {
|
|
464
|
+
await expect(
|
|
465
|
+
buildBetterAuthOptions(
|
|
466
|
+
makeOpensaasConfig(
|
|
467
|
+
makeAuthConfig({ betterAuthOptions: { rateLimit: { storage: 'database' } } }),
|
|
468
|
+
),
|
|
469
|
+
makeContext(),
|
|
470
|
+
),
|
|
471
|
+
).rejects.toThrow(/betterAuthOptions\.rateLimit\.storage/)
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
it('does not reject other betterAuthOptions.rateLimit keys (customRules/customStorage) and merges them', async () => {
|
|
475
|
+
const customRules = { '/sign-in/email': { window: 10, max: 3 } }
|
|
476
|
+
const config = await buildBetterAuthConfig(
|
|
477
|
+
makeAuthConfig({
|
|
478
|
+
rateLimit: { enabled: true, window: 60, max: 100 },
|
|
479
|
+
betterAuthOptions: {
|
|
480
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test-only shape
|
|
481
|
+
rateLimit: { customRules } as any,
|
|
482
|
+
},
|
|
483
|
+
}),
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- narrow test-only access
|
|
487
|
+
const rateLimit = config.rateLimit as any
|
|
488
|
+
expect(rateLimit.customRules).toBe(customRules)
|
|
489
|
+
// The stack's own enabled/window/max survive the merge alongside customRules.
|
|
490
|
+
expect(rateLimit.enabled).toBe(true)
|
|
491
|
+
expect(rateLimit.window).toBe(60)
|
|
492
|
+
expect(rateLimit.max).toBe(100)
|
|
493
|
+
})
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
describe('rateLimit option forwarding (issue #909)', () => {
|
|
497
|
+
beforeEach(() => {
|
|
498
|
+
betterAuthMock.mockClear()
|
|
499
|
+
prismaAdapterMock.mockClear()
|
|
500
|
+
nextCookiesMock.mockClear()
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
it('forwards enabled/window/max with no storage key when rateLimit.storage is unset', async () => {
|
|
504
|
+
const config = await buildBetterAuthConfig(
|
|
505
|
+
makeAuthConfig({ rateLimit: { enabled: true, window: 60, max: 100 } }),
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
expect(config.rateLimit).toEqual({ enabled: true, window: 60, max: 100 })
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
it('forwards storage: "database" alongside enabled/window/max', async () => {
|
|
512
|
+
const config = await buildBetterAuthConfig(
|
|
513
|
+
makeAuthConfig({
|
|
514
|
+
rateLimit: { enabled: true, window: 60, max: 100, storage: 'database' },
|
|
515
|
+
models: {
|
|
516
|
+
user: { modelName: 'User', fields: {} },
|
|
517
|
+
session: { modelName: 'Session', fields: {} },
|
|
518
|
+
account: { modelName: 'Account', fields: {} },
|
|
519
|
+
verification: { modelName: 'Verification', fields: {} },
|
|
520
|
+
rateLimit: { modelName: 'RateLimit', fields: {} },
|
|
521
|
+
},
|
|
522
|
+
}),
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
expect(config.rateLimit).toMatchObject({
|
|
526
|
+
enabled: true,
|
|
527
|
+
window: 60,
|
|
528
|
+
max: 100,
|
|
529
|
+
storage: 'database',
|
|
530
|
+
modelName: 'RateLimit',
|
|
531
|
+
})
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
it('forwards a custom rateLimit modelName/fields to better-auth so the running instance matches the derived table', async () => {
|
|
535
|
+
const config = await buildBetterAuthConfig(
|
|
536
|
+
makeAuthConfig({
|
|
537
|
+
rateLimit: { enabled: true, storage: 'database' },
|
|
538
|
+
models: {
|
|
539
|
+
user: { modelName: 'User', fields: {} },
|
|
540
|
+
session: { modelName: 'Session', fields: {} },
|
|
541
|
+
account: { modelName: 'Account', fields: {} },
|
|
542
|
+
verification: { modelName: 'Verification', fields: {} },
|
|
543
|
+
rateLimit: { modelName: 'AuthRateLimit', fields: { key: 'limit_key' } },
|
|
544
|
+
},
|
|
545
|
+
}),
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- narrow test-only access
|
|
549
|
+
const rateLimit = config.rateLimit as any
|
|
550
|
+
expect(rateLimit.modelName).toBe('AuthRateLimit')
|
|
551
|
+
expect(rateLimit.fields).toEqual({ key: 'limit_key' })
|
|
552
|
+
})
|
|
553
|
+
|
|
554
|
+
it('does not forward modelName/fields when no rateLimit model was derived (storage unset)', async () => {
|
|
555
|
+
const config = await buildBetterAuthConfig(makeAuthConfig({ rateLimit: { enabled: true } }))
|
|
556
|
+
|
|
557
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- narrow test-only access
|
|
558
|
+
const rateLimit = config.rateLimit as any
|
|
559
|
+
expect(rateLimit.modelName).toBeUndefined()
|
|
560
|
+
expect(rateLimit.fields).toBeUndefined()
|
|
561
|
+
})
|
|
462
562
|
})
|
|
463
563
|
|
|
464
564
|
describe('buildBetterAuthOptions / createAuth parity', () => {
|
|
@@ -481,6 +581,108 @@ describe('buildBetterAuthOptions / createAuth parity', () => {
|
|
|
481
581
|
expect(betterAuthMock).toHaveBeenCalledTimes(1)
|
|
482
582
|
expect(betterAuthMock.mock.calls[0][0]).toEqual(built)
|
|
483
583
|
})
|
|
584
|
+
|
|
585
|
+
it('createAuth with a plugin tuple constructs betterAuth with exactly what buildBetterAuthOptions returns for the same tuple', async () => {
|
|
586
|
+
const pluginA = { id: 'plugin-a' }
|
|
587
|
+
const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA] })
|
|
588
|
+
const opensaasConfig = makeOpensaasConfig(authConfig)
|
|
589
|
+
const context = makeContext()
|
|
590
|
+
|
|
591
|
+
const built = await buildBetterAuthOptions(opensaasConfig, context, [pluginA])
|
|
592
|
+
|
|
593
|
+
const auth = createAuth(opensaasConfig, context, [pluginA])
|
|
594
|
+
await auth.api.getSession({})
|
|
595
|
+
|
|
596
|
+
expect(betterAuthMock).toHaveBeenCalledTimes(1)
|
|
597
|
+
expect(betterAuthMock.mock.calls[0][0]).toEqual(built)
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
it('createAuth rejects when its plugin tuple does not match the resolved betterAuthPlugins', async () => {
|
|
601
|
+
const pluginA = { id: 'plugin-a' }
|
|
602
|
+
const differentInstance = { id: 'plugin-a' }
|
|
603
|
+
const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA] })
|
|
604
|
+
const opensaasConfig = makeOpensaasConfig(authConfig)
|
|
605
|
+
const context = makeContext()
|
|
606
|
+
|
|
607
|
+
const auth = createAuth(opensaasConfig, context, [differentInstance])
|
|
608
|
+
|
|
609
|
+
await expect(auth.api.getSession({})).rejects.toThrow(
|
|
610
|
+
/does not match the plugin array resolved/,
|
|
611
|
+
)
|
|
612
|
+
expect(betterAuthMock).not.toHaveBeenCalled()
|
|
613
|
+
})
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
describe('buildBetterAuthOptions plugin-tuple argument', () => {
|
|
617
|
+
beforeEach(() => {
|
|
618
|
+
betterAuthMock.mockClear()
|
|
619
|
+
prismaAdapterMock.mockClear()
|
|
620
|
+
nextCookiesMock.mockClear()
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
it('rejects when the supplied tuple has a different length than the resolved betterAuthPlugins', async () => {
|
|
624
|
+
const pluginA = { id: 'plugin-a' }
|
|
625
|
+
const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA] })
|
|
626
|
+
|
|
627
|
+
await expect(
|
|
628
|
+
buildBetterAuthOptions(makeOpensaasConfig(authConfig), makeContext(), []),
|
|
629
|
+
).rejects.toThrow(/has 0 plugin\(s\), but the plugin array resolved.*has 1/)
|
|
630
|
+
})
|
|
631
|
+
|
|
632
|
+
it('rejects naming the mismatching index when a supplied plugin is not the same instance', async () => {
|
|
633
|
+
const pluginA = { id: 'plugin-a' }
|
|
634
|
+
const pluginB = { id: 'plugin-b' }
|
|
635
|
+
const differentInstance = { id: 'plugin-a' } // same id, different identity
|
|
636
|
+
|
|
637
|
+
const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA, pluginB] })
|
|
638
|
+
|
|
639
|
+
await expect(
|
|
640
|
+
buildBetterAuthOptions(makeOpensaasConfig(authConfig), makeContext(), [
|
|
641
|
+
differentInstance,
|
|
642
|
+
pluginB,
|
|
643
|
+
]),
|
|
644
|
+
).rejects.toThrow(/at index 0/)
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
it('rejects naming the mismatching index when the supplied order differs', async () => {
|
|
648
|
+
const pluginA = { id: 'plugin-a' }
|
|
649
|
+
const pluginB = { id: 'plugin-b' }
|
|
650
|
+
const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA, pluginB] })
|
|
651
|
+
|
|
652
|
+
await expect(
|
|
653
|
+
buildBetterAuthOptions(makeOpensaasConfig(authConfig), makeContext(), [pluginB, pluginA]),
|
|
654
|
+
).rejects.toThrow(/at index 0/)
|
|
655
|
+
})
|
|
656
|
+
|
|
657
|
+
it('does not throw when the supplied tuple is the exact same instances in the same order', async () => {
|
|
658
|
+
const pluginA = { id: 'plugin-a' }
|
|
659
|
+
const pluginB = { id: 'plugin-b' }
|
|
660
|
+
const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA, pluginB] })
|
|
661
|
+
|
|
662
|
+
const config = await buildBetterAuthOptions(makeOpensaasConfig(authConfig), makeContext(), [
|
|
663
|
+
pluginA,
|
|
664
|
+
pluginB,
|
|
665
|
+
])
|
|
666
|
+
|
|
667
|
+
expect(config.plugins).toEqual([pluginA, pluginB, { id: 'next-cookies' }])
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
it('appends exactly one nextCookies() plugin, last, whether or not a plugin tuple is supplied', async () => {
|
|
671
|
+
const pluginA = { id: 'plugin-a' }
|
|
672
|
+
const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA] })
|
|
673
|
+
const opensaasConfig = makeOpensaasConfig(authConfig)
|
|
674
|
+
const context = makeContext()
|
|
675
|
+
|
|
676
|
+
const withoutArg = await buildBetterAuthOptions(opensaasConfig, context)
|
|
677
|
+
expect(nextCookiesMock).toHaveBeenCalledTimes(1)
|
|
678
|
+
expect(withoutArg.plugins).toEqual([pluginA, { id: 'next-cookies' }])
|
|
679
|
+
|
|
680
|
+
nextCookiesMock.mockClear()
|
|
681
|
+
|
|
682
|
+
const withArg = await buildBetterAuthOptions(opensaasConfig, context, [pluginA])
|
|
683
|
+
expect(nextCookiesMock).toHaveBeenCalledTimes(1)
|
|
684
|
+
expect(withArg.plugins).toEqual([pluginA, { id: 'next-cookies' }])
|
|
685
|
+
})
|
|
484
686
|
})
|
|
485
687
|
|
|
486
688
|
describe('getSessionFromAuth', () => {
|
|
@@ -504,14 +706,118 @@ describe('getSessionFromAuth', () => {
|
|
|
504
706
|
expect(result).toBeNull()
|
|
505
707
|
})
|
|
506
708
|
|
|
507
|
-
it('
|
|
709
|
+
it('propagates an error thrown by the underlying session lookup, distinguishable from no session', async () => {
|
|
508
710
|
const getSession = vi.fn(async () => {
|
|
509
711
|
throw new Error('boom')
|
|
510
712
|
})
|
|
511
713
|
const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
|
|
512
714
|
|
|
513
|
-
|
|
715
|
+
await expect(getSessionFromAuth(auth, ['userId'], new Headers())).rejects.toThrow('boom')
|
|
716
|
+
})
|
|
514
717
|
|
|
515
|
-
|
|
718
|
+
it('resolves the documented happy path unchanged: fields on the user, userId from user.id', async () => {
|
|
719
|
+
const getSession = vi.fn(async () => ({
|
|
720
|
+
user: { id: 'user-1', email: 'a@b.com', name: 'Ada' },
|
|
721
|
+
}))
|
|
722
|
+
const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
|
|
723
|
+
|
|
724
|
+
const result = await getSessionFromAuth(auth, ['userId', 'email', 'name'], new Headers())
|
|
725
|
+
|
|
726
|
+
expect(result).toEqual({ userId: 'user-1', email: 'a@b.com', name: 'Ada' })
|
|
727
|
+
})
|
|
728
|
+
|
|
729
|
+
it('projects a customSession shape with no top-level user key instead of reporting anonymous', async () => {
|
|
730
|
+
// A customSession plugin can fully replace the resolved shape (e.g.
|
|
731
|
+
// nesting fields under a custom key) and drop the `user` object entirely
|
|
732
|
+
// — that must still be treated as "a session", not "no session".
|
|
733
|
+
const getSession = vi.fn(async () => ({
|
|
734
|
+
email: 'nested@example.com',
|
|
735
|
+
data: { role: 'admin' },
|
|
736
|
+
}))
|
|
737
|
+
const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
|
|
738
|
+
|
|
739
|
+
const result = await getSessionFromAuth(auth, ['email'], new Headers())
|
|
740
|
+
|
|
741
|
+
expect(result).not.toBeNull()
|
|
742
|
+
expect(result).toEqual({ email: 'nested@example.com' })
|
|
743
|
+
})
|
|
744
|
+
|
|
745
|
+
it('resolves a field living on the session sub-object, not just the user', async () => {
|
|
746
|
+
const getSession = vi.fn(async () => ({
|
|
747
|
+
user: { id: 'user-1' },
|
|
748
|
+
session: { impersonatedBy: 'admin-1' },
|
|
749
|
+
}))
|
|
750
|
+
const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
|
|
751
|
+
|
|
752
|
+
const result = await getSessionFromAuth(auth, ['userId', 'impersonatedBy'], new Headers())
|
|
753
|
+
|
|
754
|
+
expect(result).toEqual({ userId: 'user-1', impersonatedBy: 'admin-1' })
|
|
755
|
+
})
|
|
756
|
+
|
|
757
|
+
describe('resolution precedence', () => {
|
|
758
|
+
it('prefers a top-level key over the same name on user or session (deliberate collision)', async () => {
|
|
759
|
+
const getSession = vi.fn(async () => ({
|
|
760
|
+
role: 'top-level-role',
|
|
761
|
+
user: { role: 'user-role' },
|
|
762
|
+
session: { role: 'session-role' },
|
|
763
|
+
}))
|
|
764
|
+
const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
|
|
765
|
+
|
|
766
|
+
const result = await getSessionFromAuth(auth, ['role'], new Headers())
|
|
767
|
+
|
|
768
|
+
expect(result).toEqual({ role: 'top-level-role' })
|
|
769
|
+
})
|
|
770
|
+
|
|
771
|
+
it('prefers the user object over the session sub-object when there is no top-level key', async () => {
|
|
772
|
+
const getSession = vi.fn(async () => ({
|
|
773
|
+
user: { role: 'user-role' },
|
|
774
|
+
session: { role: 'session-role' },
|
|
775
|
+
}))
|
|
776
|
+
const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
|
|
777
|
+
|
|
778
|
+
const result = await getSessionFromAuth(auth, ['role'], new Headers())
|
|
779
|
+
|
|
780
|
+
expect(result).toEqual({ role: 'user-role' })
|
|
781
|
+
})
|
|
782
|
+
})
|
|
783
|
+
|
|
784
|
+
// The warn-once cache is module-level state, so these tests re-import the
|
|
785
|
+
// module fresh via vi.resetModules() — same pattern as the `select` no-op
|
|
786
|
+
// warning tests in packages/core/tests/context.test.ts.
|
|
787
|
+
describe('unresolved field warning', () => {
|
|
788
|
+
let warnSpy: ReturnType<typeof vi.spyOn>
|
|
789
|
+
let freshGetSessionFromAuth: typeof getSessionFromAuth
|
|
790
|
+
|
|
791
|
+
beforeEach(async () => {
|
|
792
|
+
vi.resetModules()
|
|
793
|
+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
794
|
+
const mod = await import('../src/server/index.js')
|
|
795
|
+
freshGetSessionFromAuth = mod.getSessionFromAuth
|
|
796
|
+
})
|
|
797
|
+
|
|
798
|
+
afterEach(() => {
|
|
799
|
+
warnSpy.mockRestore()
|
|
800
|
+
})
|
|
801
|
+
|
|
802
|
+
it('omits an unresolvable field, warns once naming it, and does not throw', async () => {
|
|
803
|
+
const getSession = vi.fn(async () => ({ user: { id: 'user-1' } }))
|
|
804
|
+
const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
|
|
805
|
+
|
|
806
|
+
const result = await freshGetSessionFromAuth(auth, ['userId', 'nickname'], new Headers())
|
|
807
|
+
|
|
808
|
+
expect(result).toEqual({ userId: 'user-1' })
|
|
809
|
+
expect(warnSpy).toHaveBeenCalledTimes(1)
|
|
810
|
+
expect(warnSpy.mock.calls[0][0]).toContain('"nickname"')
|
|
811
|
+
})
|
|
812
|
+
|
|
813
|
+
it('does not warn again for the same field on a second call', async () => {
|
|
814
|
+
const getSession = vi.fn(async () => ({ user: { id: 'user-1' } }))
|
|
815
|
+
const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
|
|
816
|
+
|
|
817
|
+
await freshGetSessionFromAuth(auth, ['nickname'], new Headers())
|
|
818
|
+
await freshGetSessionFromAuth(auth, ['nickname'], new Headers())
|
|
819
|
+
|
|
820
|
+
expect(warnSpy).toHaveBeenCalledTimes(1)
|
|
821
|
+
})
|
|
516
822
|
})
|
|
517
823
|
})
|