@opensaas/stack-auth 0.35.0 → 0.37.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 +111 -0
- package/CLAUDE.md +104 -7
- package/README.md +18 -7
- package/dist/config/adopt-better-auth-tables.d.ts +47 -0
- package/dist/config/adopt-better-auth-tables.d.ts.map +1 -1
- package/dist/config/adopt-better-auth-tables.js +29 -1
- package/dist/config/adopt-better-auth-tables.js.map +1 -1
- package/dist/config/derive-auth-lists.d.ts +7 -5
- package/dist/config/derive-auth-lists.d.ts.map +1 -1
- package/dist/config/derive-auth-lists.js +33 -34
- package/dist/config/derive-auth-lists.js.map +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +41 -11
- package/dist/config/index.js.map +1 -1
- package/dist/config/plugin.d.ts.map +1 -1
- package/dist/config/plugin.js +39 -27
- package/dist/config/plugin.js.map +1 -1
- package/dist/config/types.d.ts +126 -24
- package/dist/config/types.d.ts.map +1 -1
- package/dist/server/index.d.ts +29 -3
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +193 -66
- package/dist/server/index.js.map +1 -1
- package/package.json +3 -3
- package/src/config/adopt-better-auth-tables.ts +70 -1
- package/src/config/derive-auth-lists.ts +37 -38
- package/src/config/index.ts +47 -12
- package/src/config/plugin.ts +39 -27
- package/src/config/types.ts +127 -21
- package/src/server/index.ts +244 -80
- package/tests/adopt-better-auth-tables.test.ts +99 -0
- package/tests/config.test.ts +66 -8
- package/tests/derive-auth-lists.test.ts +79 -5
- package/tests/generated-fk-shape.test.ts +65 -0
- package/tests/plugin-derived-keys.test.ts +48 -0
- package/tests/server.test.ts +517 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -79,3 +79,68 @@ describe('generated auth schema — Session/Account user FK mirrors better-auth
|
|
|
79
79
|
expect(widget).toContain('@@index([ownerId])')
|
|
80
80
|
})
|
|
81
81
|
})
|
|
82
|
+
|
|
83
|
+
describe('generated auth schema — required columns mirror better-auth (issue #863)', () => {
|
|
84
|
+
it('emits a required (non-nullable) userId FK and user relation on Session and Account', async () => {
|
|
85
|
+
const schema = await generateSchema({
|
|
86
|
+
db: { provider: 'sqlite' },
|
|
87
|
+
plugins: [authPlugin({ emailAndPassword: { enabled: true } })],
|
|
88
|
+
lists: {},
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
for (const model of ['Session', 'Account']) {
|
|
92
|
+
const block = modelBlock(schema, model)
|
|
93
|
+
|
|
94
|
+
expect(block).toMatch(/userId\s+String\s/)
|
|
95
|
+
expect(block).not.toMatch(/userId\s+String\?/)
|
|
96
|
+
expect(block).toMatch(/user\s+User\s+@relation/)
|
|
97
|
+
expect(block).not.toMatch(/user\s+User\?/)
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('emits a required (non-nullable) expiresAt on Session and Verification', async () => {
|
|
102
|
+
const schema = await generateSchema({
|
|
103
|
+
db: { provider: 'sqlite' },
|
|
104
|
+
plugins: [authPlugin({ emailAndPassword: { enabled: true } })],
|
|
105
|
+
lists: {},
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
for (const model of ['Session', 'Verification']) {
|
|
109
|
+
const block = modelBlock(schema, model)
|
|
110
|
+
|
|
111
|
+
expect(block).toMatch(/expiresAt\s+DateTime\s/)
|
|
112
|
+
expect(block).not.toMatch(/expiresAt\s+DateTime\?/)
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
describe('generated auth schema — tableName independent of modelName (issue #862)', () => {
|
|
118
|
+
it('emits a prefixed model name with a @@map to a better-auth default lowercase table', async () => {
|
|
119
|
+
const schema = await generateSchema({
|
|
120
|
+
db: { provider: 'postgresql' },
|
|
121
|
+
plugins: [
|
|
122
|
+
authPlugin({
|
|
123
|
+
user: { modelName: 'AuthUser', tableName: 'user' },
|
|
124
|
+
session: { modelName: 'AuthSession', tableName: 'session' },
|
|
125
|
+
account: { modelName: 'AuthAccount', tableName: 'account' },
|
|
126
|
+
verification: { modelName: 'AuthVerification', tableName: 'verification' },
|
|
127
|
+
emailAndPassword: { enabled: true },
|
|
128
|
+
}),
|
|
129
|
+
],
|
|
130
|
+
lists: {},
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
for (const [model, table] of [
|
|
134
|
+
['AuthUser', 'user'],
|
|
135
|
+
['AuthSession', 'session'],
|
|
136
|
+
['AuthAccount', 'account'],
|
|
137
|
+
['AuthVerification', 'verification'],
|
|
138
|
+
]) {
|
|
139
|
+
const block = modelBlock(schema, model)
|
|
140
|
+
// The generated model keeps the prefixed name but maps to the live
|
|
141
|
+
// lowercase table — no DROP/CREATE rename against a real better-auth
|
|
142
|
+
// install using its own default table names.
|
|
143
|
+
expect(block).toContain(`@@map("${table}")`)
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
})
|
|
@@ -161,6 +161,54 @@ describe('authPlugin - add-vs-extend with derived keys', () => {
|
|
|
161
161
|
expect(appUser.access?.operation?.update?.({} as any)).toBe(false)
|
|
162
162
|
expect(appUser.access?.operation?.update).toBe(hostUserUpdate)
|
|
163
163
|
})
|
|
164
|
+
|
|
165
|
+
it('preserves the derived Auth list db (map/schema/timestamps) and access when a better-auth plugin extends a base model (#861)', async () => {
|
|
166
|
+
// A better-auth provider plugin (e.g. the `anonymous` plugin) that ships a
|
|
167
|
+
// schema extension for the base `user` model — the exact shape adoptBetterAuthTables()
|
|
168
|
+
// + betterAuthPlugins produces in the field. Before the #861 fix, the
|
|
169
|
+
// plugin's schema loop ran BEFORE the derived Auth lists were added, so it
|
|
170
|
+
// pre-empted `AuthUser` with a bare `list({ fields })` (no `db`, no
|
|
171
|
+
// `access`) — and the real derived list then lost to the field-only
|
|
172
|
+
// `extendList` merge, silently dropping `@@map`/`@@schema`/timestamps/access.
|
|
173
|
+
const anonymousBetterAuthPlugin = {
|
|
174
|
+
id: 'anonymous',
|
|
175
|
+
schema: {
|
|
176
|
+
user: {
|
|
177
|
+
fields: {
|
|
178
|
+
isAnonymous: { type: 'boolean', required: false },
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const userQuery = vi.fn(() => true)
|
|
185
|
+
const result = await config({
|
|
186
|
+
db: { provider: 'postgresql' },
|
|
187
|
+
plugins: [
|
|
188
|
+
authPlugin({
|
|
189
|
+
schema: 'auth',
|
|
190
|
+
user: { modelName: 'AuthUser' },
|
|
191
|
+
session: { modelName: 'AuthSession' },
|
|
192
|
+
account: { modelName: 'AuthAccount' },
|
|
193
|
+
verification: { modelName: 'AuthVerification' },
|
|
194
|
+
betterAuthPlugins: [anonymousBetterAuthPlugin],
|
|
195
|
+
access: { user: { operation: { query: userQuery } } },
|
|
196
|
+
}),
|
|
197
|
+
],
|
|
198
|
+
lists: {},
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
const authUser = result.lists.AuthUser
|
|
202
|
+
|
|
203
|
+
// The plugin's schema extension is merged in...
|
|
204
|
+
expect(authUser.fields).toHaveProperty('isAnonymous')
|
|
205
|
+
// ...without displacing the derived list's own db config...
|
|
206
|
+
expect(authUser.db?.map).toBe('AuthUser')
|
|
207
|
+
expect(authUser.db?.schema).toBe('auth')
|
|
208
|
+
expect(authUser.db?.timestamps).toBe(true)
|
|
209
|
+
// ...or its access config.
|
|
210
|
+
expect(authUser.access?.operation?.query).toBe(userQuery)
|
|
211
|
+
})
|
|
164
212
|
})
|
|
165
213
|
|
|
166
214
|
describe('authPlugin - runtime user-key resolution', () => {
|
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import type { BetterAuthOptions } from 'better-auth'
|
|
3
|
+
import type { NormalizedAuthConfig } from '../src/config/types.js'
|
|
4
|
+
import type { OpenSaasConfig, AccessContext } from '@opensaas/stack-core'
|
|
5
|
+
|
|
6
|
+
const betterAuthMock = vi.fn(() => ({ api: { getSession: vi.fn(async () => null) } }))
|
|
7
|
+
const prismaAdapterMock = vi.fn((client: unknown, opts: unknown) => ({ client, opts }))
|
|
8
|
+
const nextCookiesMock = vi.fn(() => ({ id: 'next-cookies' }))
|
|
9
|
+
|
|
10
|
+
vi.mock('better-auth', () => ({
|
|
11
|
+
betterAuth: betterAuthMock,
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
vi.mock('better-auth/adapters/prisma', () => ({
|
|
15
|
+
prismaAdapter: prismaAdapterMock,
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
vi.mock('better-auth/next-js', () => ({
|
|
19
|
+
nextCookies: nextCookiesMock,
|
|
20
|
+
}))
|
|
21
|
+
|
|
22
|
+
const { createAuth, buildBetterAuthOptions, getSessionFromAuth } =
|
|
23
|
+
await import('../src/server/index.js')
|
|
24
|
+
|
|
25
|
+
function makeAuthConfig(overrides: Partial<NormalizedAuthConfig> = {}): NormalizedAuthConfig {
|
|
26
|
+
return {
|
|
27
|
+
emailAndPassword: {
|
|
28
|
+
enabled: true,
|
|
29
|
+
minPasswordLength: 8,
|
|
30
|
+
requireConfirmation: true,
|
|
31
|
+
sendResetPassword: vi.fn(async () => {}),
|
|
32
|
+
},
|
|
33
|
+
emailVerification: {
|
|
34
|
+
enabled: false,
|
|
35
|
+
sendOnSignUp: true,
|
|
36
|
+
tokenExpiration: 86400,
|
|
37
|
+
sendVerificationEmail: vi.fn(async () => {}),
|
|
38
|
+
},
|
|
39
|
+
passwordReset: { enabled: false, tokenExpiration: 3600 },
|
|
40
|
+
socialProviders: {},
|
|
41
|
+
session: { expiresIn: 604800, updateAge: 86400 },
|
|
42
|
+
models: {
|
|
43
|
+
user: { modelName: 'User', fields: {} },
|
|
44
|
+
session: { modelName: 'Session', fields: {} },
|
|
45
|
+
account: { modelName: 'Account', fields: {} },
|
|
46
|
+
verification: { modelName: 'Verification', fields: {} },
|
|
47
|
+
},
|
|
48
|
+
sessionFields: ['userId', 'email', 'name'],
|
|
49
|
+
extendUserList: {},
|
|
50
|
+
access: {},
|
|
51
|
+
betterAuthPlugins: [],
|
|
52
|
+
rateLimit: undefined,
|
|
53
|
+
betterAuthOptions: {},
|
|
54
|
+
...overrides,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function makeOpensaasConfig(authConfig: NormalizedAuthConfig): OpenSaasConfig {
|
|
59
|
+
return {
|
|
60
|
+
db: { provider: 'sqlite' },
|
|
61
|
+
lists: {},
|
|
62
|
+
_pluginData: { auth: authConfig },
|
|
63
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal test fixture
|
|
64
|
+
} as any
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function makeContext(): AccessContext {
|
|
68
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal test fixture
|
|
69
|
+
return { prisma: { __mockPrisma: true } } as any
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function buildBetterAuthConfig(authConfig: NormalizedAuthConfig): Promise<BetterAuthOptions> {
|
|
73
|
+
const auth = createAuth(makeOpensaasConfig(authConfig), makeContext())
|
|
74
|
+
// Calling a nested method is what actually forces the lazy proxy to resolve
|
|
75
|
+
// the underlying betterAuth() instance — merely accessing `auth.api` does not.
|
|
76
|
+
await auth.api.getSession({})
|
|
77
|
+
expect(betterAuthMock).toHaveBeenCalledTimes(1)
|
|
78
|
+
return betterAuthMock.mock.calls[0][0] as BetterAuthOptions
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe('createAuth', () => {
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
betterAuthMock.mockClear()
|
|
84
|
+
prismaAdapterMock.mockClear()
|
|
85
|
+
nextCookiesMock.mockClear()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('forwards emailAndPassword.minPasswordLength', async () => {
|
|
89
|
+
const config = await buildBetterAuthConfig(
|
|
90
|
+
makeAuthConfig({
|
|
91
|
+
emailAndPassword: {
|
|
92
|
+
enabled: true,
|
|
93
|
+
minPasswordLength: 12,
|
|
94
|
+
requireConfirmation: true,
|
|
95
|
+
sendResetPassword: vi.fn(async () => {}),
|
|
96
|
+
},
|
|
97
|
+
}),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
expect(config.emailAndPassword).toMatchObject({ enabled: true, minPasswordLength: 12 })
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('omits emailAndPassword entirely when disabled', async () => {
|
|
104
|
+
const config = await buildBetterAuthConfig(
|
|
105
|
+
makeAuthConfig({
|
|
106
|
+
emailAndPassword: {
|
|
107
|
+
enabled: false,
|
|
108
|
+
minPasswordLength: 8,
|
|
109
|
+
requireConfirmation: true,
|
|
110
|
+
sendResetPassword: vi.fn(async () => {}),
|
|
111
|
+
},
|
|
112
|
+
}),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
expect(config.emailAndPassword).toBeUndefined()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('forwards emailVerification.sendOnSignUp and tokenExpiration when enabled', async () => {
|
|
119
|
+
const config = await buildBetterAuthConfig(
|
|
120
|
+
makeAuthConfig({
|
|
121
|
+
emailVerification: {
|
|
122
|
+
enabled: true,
|
|
123
|
+
sendOnSignUp: false,
|
|
124
|
+
tokenExpiration: 1234,
|
|
125
|
+
sendVerificationEmail: vi.fn(async () => {}),
|
|
126
|
+
},
|
|
127
|
+
}),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
expect(config.emailVerification).toBeDefined()
|
|
131
|
+
expect(config.emailVerification?.sendOnSignUp).toBe(false)
|
|
132
|
+
expect(config.emailVerification?.expiresIn).toBe(1234)
|
|
133
|
+
expect(typeof config.emailVerification?.sendVerificationEmail).toBe('function')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('omits emailVerification entirely when disabled', async () => {
|
|
137
|
+
const config = await buildBetterAuthConfig(
|
|
138
|
+
makeAuthConfig({
|
|
139
|
+
emailVerification: {
|
|
140
|
+
enabled: false,
|
|
141
|
+
sendOnSignUp: true,
|
|
142
|
+
tokenExpiration: 86400,
|
|
143
|
+
sendVerificationEmail: vi.fn(async () => {}),
|
|
144
|
+
},
|
|
145
|
+
}),
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
expect(config.emailVerification).toBeUndefined()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('forwards the configured sendVerificationEmail callback straight through, unwrapped', async () => {
|
|
152
|
+
const sendVerificationEmail = vi.fn(async () => {})
|
|
153
|
+
const config = await buildBetterAuthConfig(
|
|
154
|
+
makeAuthConfig({
|
|
155
|
+
emailVerification: {
|
|
156
|
+
enabled: true,
|
|
157
|
+
sendOnSignUp: true,
|
|
158
|
+
tokenExpiration: 86400,
|
|
159
|
+
sendVerificationEmail,
|
|
160
|
+
},
|
|
161
|
+
}),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
expect(config.emailVerification?.sendVerificationEmail).toBe(sendVerificationEmail)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('forwards passwordReset.tokenExpiration and the configured sendResetPassword callback straight through, unwrapped', async () => {
|
|
168
|
+
const sendResetPassword = vi.fn(async () => {})
|
|
169
|
+
const config = await buildBetterAuthConfig(
|
|
170
|
+
makeAuthConfig({
|
|
171
|
+
passwordReset: { enabled: true, tokenExpiration: 4321 },
|
|
172
|
+
emailAndPassword: {
|
|
173
|
+
enabled: true,
|
|
174
|
+
minPasswordLength: 8,
|
|
175
|
+
requireConfirmation: true,
|
|
176
|
+
sendResetPassword,
|
|
177
|
+
},
|
|
178
|
+
}),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- narrow test-only access
|
|
182
|
+
const emailAndPassword = config.emailAndPassword as any
|
|
183
|
+
expect(emailAndPassword.resetPasswordTokenExpiresIn).toBe(4321)
|
|
184
|
+
expect(emailAndPassword.sendResetPassword).toBe(sendResetPassword)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('does not add sendResetPassword when passwordReset is disabled', async () => {
|
|
188
|
+
const config = await buildBetterAuthConfig(
|
|
189
|
+
makeAuthConfig({ passwordReset: { enabled: false, tokenExpiration: 3600 } }),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- narrow test-only access
|
|
193
|
+
const emailAndPassword = config.emailAndPassword as any
|
|
194
|
+
expect(emailAndPassword.sendResetPassword).toBeUndefined()
|
|
195
|
+
expect(emailAndPassword.resetPasswordTokenExpiresIn).toBeUndefined()
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('passes session.updateAge through as a number', async () => {
|
|
199
|
+
const config = await buildBetterAuthConfig(
|
|
200
|
+
makeAuthConfig({ session: { expiresIn: 604800, updateAge: 3600 } }),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
expect(config.session?.updateAge).toBe(3600)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('sets disableSessionRefresh instead of updateAge: 0 when updateAge is false', async () => {
|
|
207
|
+
const config = await buildBetterAuthConfig(
|
|
208
|
+
makeAuthConfig({ session: { expiresIn: 604800, updateAge: false } }),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
// better-auth treats `updateAge: 0` as "refresh on every request", not
|
|
212
|
+
// "never refresh" — disabling refresh requires the separate flag.
|
|
213
|
+
expect(config.session?.disableSessionRefresh).toBe(true)
|
|
214
|
+
expect(config.session?.updateAge).toBeUndefined()
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('warns when requireConfirmation is set to false since it has no server-side effect', async () => {
|
|
218
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
219
|
+
|
|
220
|
+
await buildBetterAuthConfig(
|
|
221
|
+
makeAuthConfig({
|
|
222
|
+
emailAndPassword: {
|
|
223
|
+
enabled: true,
|
|
224
|
+
minPasswordLength: 8,
|
|
225
|
+
requireConfirmation: false,
|
|
226
|
+
sendResetPassword: vi.fn(async () => {}),
|
|
227
|
+
},
|
|
228
|
+
}),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('requireConfirmation'))
|
|
232
|
+
warnSpy.mockRestore()
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('does not warn when requireConfirmation is left at its default (true)', async () => {
|
|
236
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
237
|
+
|
|
238
|
+
await buildBetterAuthConfig(
|
|
239
|
+
makeAuthConfig({
|
|
240
|
+
emailAndPassword: {
|
|
241
|
+
enabled: true,
|
|
242
|
+
minPasswordLength: 8,
|
|
243
|
+
requireConfirmation: true,
|
|
244
|
+
sendResetPassword: vi.fn(async () => {}),
|
|
245
|
+
},
|
|
246
|
+
}),
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
expect(warnSpy).not.toHaveBeenCalled()
|
|
250
|
+
warnSpy.mockRestore()
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('does not warn about requireConfirmation when emailAndPassword is disabled', async () => {
|
|
254
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
255
|
+
|
|
256
|
+
await buildBetterAuthConfig(
|
|
257
|
+
makeAuthConfig({
|
|
258
|
+
emailAndPassword: {
|
|
259
|
+
enabled: false,
|
|
260
|
+
minPasswordLength: 8,
|
|
261
|
+
requireConfirmation: false,
|
|
262
|
+
sendResetPassword: vi.fn(async () => {}),
|
|
263
|
+
},
|
|
264
|
+
}),
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('requireConfirmation'))
|
|
268
|
+
warnSpy.mockRestore()
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('warns when passwordReset is enabled but emailAndPassword is disabled', async () => {
|
|
272
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
273
|
+
|
|
274
|
+
await buildBetterAuthConfig(
|
|
275
|
+
makeAuthConfig({
|
|
276
|
+
emailAndPassword: {
|
|
277
|
+
enabled: false,
|
|
278
|
+
minPasswordLength: 8,
|
|
279
|
+
requireConfirmation: true,
|
|
280
|
+
sendResetPassword: vi.fn(async () => {}),
|
|
281
|
+
},
|
|
282
|
+
passwordReset: { enabled: true, tokenExpiration: 3600 },
|
|
283
|
+
}),
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('passwordReset'))
|
|
287
|
+
warnSpy.mockRestore()
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('does not warn about passwordReset when emailAndPassword is enabled', async () => {
|
|
291
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
292
|
+
|
|
293
|
+
await buildBetterAuthConfig(
|
|
294
|
+
makeAuthConfig({
|
|
295
|
+
emailAndPassword: {
|
|
296
|
+
enabled: true,
|
|
297
|
+
minPasswordLength: 8,
|
|
298
|
+
requireConfirmation: true,
|
|
299
|
+
sendResetPassword: vi.fn(async () => {}),
|
|
300
|
+
},
|
|
301
|
+
passwordReset: { enabled: true, tokenExpiration: 3600 },
|
|
302
|
+
}),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('passwordReset'))
|
|
306
|
+
warnSpy.mockRestore()
|
|
307
|
+
})
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
describe('betterAuthOptions passthrough', () => {
|
|
311
|
+
beforeEach(() => {
|
|
312
|
+
betterAuthMock.mockClear()
|
|
313
|
+
prismaAdapterMock.mockClear()
|
|
314
|
+
nextCookiesMock.mockClear()
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('produces byte-for-byte identical options to today when unused', async () => {
|
|
318
|
+
// `normalizeAuthConfig` is what actually defaults a config without
|
|
319
|
+
// `betterAuthOptions` to `{}` (see config.test.ts) — reproduce that
|
|
320
|
+
// realistic normalized shape here rather than an AuthConfig missing a
|
|
321
|
+
// required NormalizedAuthConfig field.
|
|
322
|
+
const authConfig = makeAuthConfig({ betterAuthOptions: {} })
|
|
323
|
+
|
|
324
|
+
const built = await buildBetterAuthConfig(authConfig)
|
|
325
|
+
|
|
326
|
+
expect(built).toEqual({
|
|
327
|
+
database: { client: { __mockPrisma: true }, opts: { provider: 'sqlite' } },
|
|
328
|
+
user: { modelName: 'User' },
|
|
329
|
+
session: { modelName: 'Session', expiresIn: 604800, updateAge: 86400 },
|
|
330
|
+
account: { modelName: 'Account' },
|
|
331
|
+
verification: { modelName: 'Verification' },
|
|
332
|
+
emailAndPassword: {
|
|
333
|
+
enabled: true,
|
|
334
|
+
requireEmailVerification: false,
|
|
335
|
+
minPasswordLength: 8,
|
|
336
|
+
},
|
|
337
|
+
emailVerification: undefined,
|
|
338
|
+
trustedOrigins: [],
|
|
339
|
+
socialProviders: {},
|
|
340
|
+
rateLimit: undefined,
|
|
341
|
+
plugins: [{ id: 'next-cookies' }],
|
|
342
|
+
})
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it('surfaces a top-level option the stack does not model (e.g. baseURL)', async () => {
|
|
346
|
+
const config = await buildBetterAuthConfig(
|
|
347
|
+
makeAuthConfig({ betterAuthOptions: { baseURL: 'https://example.com' } }),
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
expect(config.baseURL).toBe('https://example.com')
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
it('merges a nested database hook without clobbering sibling top-level keys', async () => {
|
|
354
|
+
const after = vi.fn()
|
|
355
|
+
const config = await buildBetterAuthConfig(
|
|
356
|
+
makeAuthConfig({
|
|
357
|
+
session: { expiresIn: 604800, updateAge: 86400 },
|
|
358
|
+
betterAuthOptions: {
|
|
359
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test-only shape
|
|
360
|
+
databaseHooks: { user: { create: { after } } } as any,
|
|
361
|
+
},
|
|
362
|
+
}),
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- narrow test-only access
|
|
366
|
+
expect((config as any).databaseHooks.user.create.after).toBe(after)
|
|
367
|
+
// The stack's own session config survives — passthrough added a sibling
|
|
368
|
+
// top-level key, it didn't replace the whole options object.
|
|
369
|
+
expect(config.session?.expiresIn).toBe(604800)
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
it('merges a nested session option without clobbering the stack-set session keys', async () => {
|
|
373
|
+
const config = await buildBetterAuthConfig(
|
|
374
|
+
makeAuthConfig({
|
|
375
|
+
session: { expiresIn: 604800, updateAge: 3600 },
|
|
376
|
+
betterAuthOptions: {
|
|
377
|
+
session: { cookieCache: { enabled: true, maxAge: 300 } },
|
|
378
|
+
},
|
|
379
|
+
}),
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- narrow test-only access
|
|
383
|
+
expect((config.session as any).cookieCache).toEqual({ enabled: true, maxAge: 300 })
|
|
384
|
+
expect(config.session?.expiresIn).toBe(604800)
|
|
385
|
+
expect(config.session?.updateAge).toBe(3600)
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
it('lets the passthrough win on a genuine key collision', async () => {
|
|
389
|
+
const config = await buildBetterAuthConfig(
|
|
390
|
+
makeAuthConfig({
|
|
391
|
+
session: { expiresIn: 604800, updateAge: 3600 },
|
|
392
|
+
betterAuthOptions: {
|
|
393
|
+
session: { expiresIn: 999 },
|
|
394
|
+
},
|
|
395
|
+
}),
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
expect(config.session?.expiresIn).toBe(999)
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
it('replaces an array outright instead of concatenating (e.g. trustedOrigins)', async () => {
|
|
402
|
+
process.env.BETTER_AUTH_TRUSTED_ORIGINS = 'https://env-origin.com'
|
|
403
|
+
try {
|
|
404
|
+
const config = await buildBetterAuthConfig(
|
|
405
|
+
makeAuthConfig({
|
|
406
|
+
betterAuthOptions: { trustedOrigins: ['https://config-origin.com'] },
|
|
407
|
+
}),
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
expect(config.trustedOrigins).toEqual(['https://config-origin.com'])
|
|
411
|
+
} finally {
|
|
412
|
+
delete process.env.BETTER_AUTH_TRUSTED_ORIGINS
|
|
413
|
+
}
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
it('rejects betterAuthOptions.database', async () => {
|
|
417
|
+
await expect(
|
|
418
|
+
buildBetterAuthOptions(
|
|
419
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test-only shape
|
|
420
|
+
makeOpensaasConfig(makeAuthConfig({ betterAuthOptions: { database: {} as any } })),
|
|
421
|
+
makeContext(),
|
|
422
|
+
),
|
|
423
|
+
).rejects.toThrow(/betterAuthOptions\.database/)
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
it('rejects betterAuthOptions.plugins', async () => {
|
|
427
|
+
await expect(
|
|
428
|
+
buildBetterAuthOptions(
|
|
429
|
+
makeOpensaasConfig(makeAuthConfig({ betterAuthOptions: { plugins: [] } })),
|
|
430
|
+
makeContext(),
|
|
431
|
+
),
|
|
432
|
+
).rejects.toThrow(/betterAuthOptions\.plugins/)
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
it.each(['user', 'session', 'account', 'verification'] as const)(
|
|
436
|
+
'rejects betterAuthOptions.%s.additionalFields',
|
|
437
|
+
async (model) => {
|
|
438
|
+
await expect(
|
|
439
|
+
buildBetterAuthOptions(
|
|
440
|
+
makeOpensaasConfig(
|
|
441
|
+
makeAuthConfig({
|
|
442
|
+
betterAuthOptions: {
|
|
443
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test-only shape
|
|
444
|
+
[model]: { additionalFields: { foo: { type: 'string' } } } as any,
|
|
445
|
+
},
|
|
446
|
+
}),
|
|
447
|
+
),
|
|
448
|
+
makeContext(),
|
|
449
|
+
),
|
|
450
|
+
).rejects.toThrow(new RegExp(`betterAuthOptions\\.${model}\\.additionalFields`))
|
|
451
|
+
},
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
it('does not reject additionalFields nested under an unrelated model key', async () => {
|
|
455
|
+
// A plain object at `user` with no `additionalFields` key must not trip the guard.
|
|
456
|
+
const config = await buildBetterAuthConfig(
|
|
457
|
+
makeAuthConfig({ betterAuthOptions: { user: { modelName: 'CustomUser' } } }),
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
expect(config.user).toMatchObject({ modelName: 'CustomUser' })
|
|
461
|
+
})
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
describe('buildBetterAuthOptions / createAuth parity', () => {
|
|
465
|
+
beforeEach(() => {
|
|
466
|
+
betterAuthMock.mockClear()
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
it('createAuth constructs betterAuth with exactly what buildBetterAuthOptions returns', async () => {
|
|
470
|
+
const authConfig = makeAuthConfig({
|
|
471
|
+
betterAuthOptions: { baseURL: 'https://example.com' },
|
|
472
|
+
})
|
|
473
|
+
const opensaasConfig = makeOpensaasConfig(authConfig)
|
|
474
|
+
const context = makeContext()
|
|
475
|
+
|
|
476
|
+
const built = await buildBetterAuthOptions(opensaasConfig, context)
|
|
477
|
+
|
|
478
|
+
const auth = createAuth(opensaasConfig, context)
|
|
479
|
+
await auth.api.getSession({})
|
|
480
|
+
|
|
481
|
+
expect(betterAuthMock).toHaveBeenCalledTimes(1)
|
|
482
|
+
expect(betterAuthMock.mock.calls[0][0]).toEqual(built)
|
|
483
|
+
})
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
describe('getSessionFromAuth', () => {
|
|
487
|
+
it('passes the caller-supplied headers to auth.api.getSession', async () => {
|
|
488
|
+
const headers = new Headers({ cookie: 'session=abc' })
|
|
489
|
+
const getSession = vi.fn(async () => ({ user: { id: 'user-1', email: 'a@b.com' } }))
|
|
490
|
+
const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
|
|
491
|
+
|
|
492
|
+
const result = await getSessionFromAuth(auth, ['userId', 'email'], headers)
|
|
493
|
+
|
|
494
|
+
expect(getSession).toHaveBeenCalledWith({ headers })
|
|
495
|
+
expect(result).toEqual({ userId: 'user-1', email: 'a@b.com' })
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
it('returns null when there is no session', async () => {
|
|
499
|
+
const getSession = vi.fn(async () => null)
|
|
500
|
+
const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
|
|
501
|
+
|
|
502
|
+
const result = await getSessionFromAuth(auth, ['userId'], new Headers())
|
|
503
|
+
|
|
504
|
+
expect(result).toBeNull()
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
it('returns null when auth.api.getSession throws', async () => {
|
|
508
|
+
const getSession = vi.fn(async () => {
|
|
509
|
+
throw new Error('boom')
|
|
510
|
+
})
|
|
511
|
+
const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
|
|
512
|
+
|
|
513
|
+
const result = await getSessionFromAuth(auth, ['userId'], new Headers())
|
|
514
|
+
|
|
515
|
+
expect(result).toBeNull()
|
|
516
|
+
})
|
|
517
|
+
})
|