@opensaas/stack-auth 0.38.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 +23 -0
- 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 +6 -1
- package/dist/config/plugin.js.map +1 -1
- package/dist/config/types.d.ts +44 -2
- package/dist/config/types.d.ts.map +1 -1
- 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 +17 -2
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +28 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/schema-converter.d.ts +12 -3
- 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 +3 -3
- 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 +6 -1
- package/src/config/types.ts +45 -2
- package/src/server/get-session-from-auth.test.ts +52 -0
- package/src/server/index.ts +35 -3
- package/src/server/schema-converter.ts +26 -5
- 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 +100 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -144,3 +144,84 @@ describe('generated auth schema — tableName independent of modelName (issue #8
|
|
|
144
144
|
}
|
|
145
145
|
})
|
|
146
146
|
})
|
|
147
|
+
|
|
148
|
+
describe('generated RateLimit schema mirrors better-auth exactly (issue #909)', () => {
|
|
149
|
+
it('does not add a RateLimit model when storage is unset', async () => {
|
|
150
|
+
const schema = await generateSchema({
|
|
151
|
+
db: { provider: 'sqlite' },
|
|
152
|
+
plugins: [authPlugin({ emailAndPassword: { enabled: true } })],
|
|
153
|
+
lists: {},
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
expect(schema).not.toContain('model RateLimit')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('emits key (unique, non-null), count (non-null Int), lastRequest (non-null BigInt), no createdAt/updatedAt, no @default', async () => {
|
|
160
|
+
const schema = await generateSchema({
|
|
161
|
+
db: { provider: 'sqlite' },
|
|
162
|
+
plugins: [
|
|
163
|
+
authPlugin({
|
|
164
|
+
emailAndPassword: { enabled: true },
|
|
165
|
+
rateLimit: { enabled: true, storage: 'database' },
|
|
166
|
+
}),
|
|
167
|
+
],
|
|
168
|
+
lists: {},
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
const block = modelBlock(schema, 'RateLimit')
|
|
172
|
+
|
|
173
|
+
expect(block).toMatch(/key\s+String\s+@unique/)
|
|
174
|
+
expect(block).toMatch(/count\s+Int\s/)
|
|
175
|
+
expect(block).not.toMatch(/count\s+Int\?/)
|
|
176
|
+
expect(block).toMatch(/lastRequest\s+BigInt\s/)
|
|
177
|
+
expect(block).not.toMatch(/lastRequest\s+BigInt\?/)
|
|
178
|
+
|
|
179
|
+
expect(block).not.toContain('createdAt')
|
|
180
|
+
expect(block).not.toContain('updatedAt')
|
|
181
|
+
// The system `id` field carries its own @default(cuid()) — only the
|
|
182
|
+
// three better-auth-mirrored columns must carry none.
|
|
183
|
+
expect(block).not.toMatch(/key\s+String\s+@unique\s+@default/)
|
|
184
|
+
expect(block).not.toMatch(/count\s+Int\s+@default/)
|
|
185
|
+
expect(block).not.toMatch(/lastRequest\s+BigInt\s+@default/)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('honours a custom modelName/tableName/fields/schema on the rateLimit model', async () => {
|
|
189
|
+
const schema = await generateSchema({
|
|
190
|
+
db: { provider: 'postgresql' },
|
|
191
|
+
plugins: [
|
|
192
|
+
authPlugin({
|
|
193
|
+
emailAndPassword: { enabled: true },
|
|
194
|
+
rateLimit: {
|
|
195
|
+
enabled: true,
|
|
196
|
+
storage: 'database',
|
|
197
|
+
modelName: 'AuthRateLimit',
|
|
198
|
+
tableName: 'rate_limit',
|
|
199
|
+
fields: { key: 'limit_key', count: 'hit_count', lastRequest: 'last_hit_at' },
|
|
200
|
+
},
|
|
201
|
+
}),
|
|
202
|
+
],
|
|
203
|
+
lists: {},
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
const block = modelBlock(schema, 'AuthRateLimit')
|
|
207
|
+
expect(block).toContain('@@map("rate_limit")')
|
|
208
|
+
expect(block).toContain('@map("limit_key")')
|
|
209
|
+
expect(block).toContain('@map("hit_count")')
|
|
210
|
+
expect(block).toContain('@map("last_hit_at")')
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('produces a RateLimit model even when enabled is false, since better-auth still expects the table', async () => {
|
|
214
|
+
const schema = await generateSchema({
|
|
215
|
+
db: { provider: 'sqlite' },
|
|
216
|
+
plugins: [
|
|
217
|
+
authPlugin({
|
|
218
|
+
emailAndPassword: { enabled: true },
|
|
219
|
+
rateLimit: { enabled: false, storage: 'database' },
|
|
220
|
+
}),
|
|
221
|
+
],
|
|
222
|
+
lists: {},
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
expect(schema).toContain('model RateLimit')
|
|
226
|
+
})
|
|
227
|
+
})
|
|
@@ -119,3 +119,42 @@ describe('authPlugin - schema placement (adopt existing auth-schema install)', (
|
|
|
119
119
|
expect(result.db.schemas).toContain('auth_internal')
|
|
120
120
|
})
|
|
121
121
|
})
|
|
122
|
+
|
|
123
|
+
describe('authPlugin - RateLimit list schema placement', () => {
|
|
124
|
+
it('places the RateLimit list in the configured schema and wires the datasource', async () => {
|
|
125
|
+
const result = await generationConfig({
|
|
126
|
+
db: { provider: 'postgresql' },
|
|
127
|
+
plugins: [
|
|
128
|
+
authPlugin({
|
|
129
|
+
schema: 'auth',
|
|
130
|
+
rateLimit: { enabled: true, storage: 'database', modelName: 'AuthRateLimit' },
|
|
131
|
+
}),
|
|
132
|
+
],
|
|
133
|
+
lists: {},
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
expect(result.lists.AuthRateLimit.db).toEqual({ map: 'AuthRateLimit', schema: 'auth' })
|
|
137
|
+
expect(result.db.schemas).toContain('auth')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('honours a per-model schema override independent of the plugin-level schema', async () => {
|
|
141
|
+
const result = await generationConfig({
|
|
142
|
+
db: { provider: 'postgresql' },
|
|
143
|
+
plugins: [
|
|
144
|
+
authPlugin({
|
|
145
|
+
schema: 'auth',
|
|
146
|
+
rateLimit: {
|
|
147
|
+
enabled: true,
|
|
148
|
+
storage: 'database',
|
|
149
|
+
modelName: 'AuthRateLimit',
|
|
150
|
+
schema: 'auth_internal',
|
|
151
|
+
},
|
|
152
|
+
}),
|
|
153
|
+
],
|
|
154
|
+
lists: {},
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
expect(result.lists.AuthRateLimit.db?.schema).toBe('auth_internal')
|
|
158
|
+
expect(result.db.schemas).toContain('auth_internal')
|
|
159
|
+
})
|
|
160
|
+
})
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { describe, it, expect, afterAll } from 'vitest'
|
|
2
|
+
import { execFile } from 'node:child_process'
|
|
3
|
+
import { promisify } from 'node:util'
|
|
4
|
+
import { existsSync } from 'node:fs'
|
|
5
|
+
import fsp from 'node:fs/promises'
|
|
6
|
+
import os from 'node:os'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import { pathToFileURL } from 'node:url'
|
|
9
|
+
import { createAuth } from '../src/server/index.js'
|
|
10
|
+
import type { OpenSaasConfig, AccessContext } from '@opensaas/stack-core'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Live end-to-end proof that the database-backed rate limiter (issue #909)
|
|
14
|
+
* actually works: it generates a real Prisma schema (with the derived
|
|
15
|
+
* `RateLimit` list), pushes it to a real SQLite database, constructs real
|
|
16
|
+
* `betterAuth()` instances against that database via `createAuth()` (the
|
|
17
|
+
* package's own source, not a stale build), and drives them through real
|
|
18
|
+
* HTTP-shaped requests via `auth.handler()`.
|
|
19
|
+
*
|
|
20
|
+
* This is the one guard in the suite that touches a live database, so it
|
|
21
|
+
* follows the same pattern as
|
|
22
|
+
* `packages/create-opensaas-app/tests/scaffold-first-run-guard.test.ts`
|
|
23
|
+
* (see ADR-0002): opt-in via an env flag, kept out of the fast unit lane, run
|
|
24
|
+
* only in the `e2e` CI job where `pnpm install && pnpm build` have already
|
|
25
|
+
* run. It borrows the `opensaas`/`prisma` binaries and the
|
|
26
|
+
* `@prisma/adapter-better-sqlite3` dependency from `examples/starter-auth`'s
|
|
27
|
+
* already-installed `node_modules` (symlinked into an OS temp dir) instead of
|
|
28
|
+
* adding a live-database toolchain to this package's own dependencies.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const run = promisify(execFile)
|
|
32
|
+
const here = path.dirname(new URL(import.meta.url).pathname)
|
|
33
|
+
const repoRoot = path.resolve(here, '../../..')
|
|
34
|
+
|
|
35
|
+
/** The `opensaas` CLI the temp project's `generate` step invokes. */
|
|
36
|
+
const opensaasCli = path.join(repoRoot, 'packages/cli/dist/index.js')
|
|
37
|
+
/** `createAuth()` (imported from source above) resolves `@opensaas/stack-core` through this build. */
|
|
38
|
+
const coreDist = path.join(repoRoot, 'packages/core/dist')
|
|
39
|
+
|
|
40
|
+
/** The toolchain (opensaas/prisma binaries + the sqlite adapter) borrowed from a working example. */
|
|
41
|
+
const toolchainNodeModules = path.join(repoRoot, 'examples/starter-auth/node_modules')
|
|
42
|
+
|
|
43
|
+
const guardEnabled = process.env.RUN_RATE_LIMIT_E2E === '1'
|
|
44
|
+
|
|
45
|
+
const prerequisitesPresent =
|
|
46
|
+
guardEnabled &&
|
|
47
|
+
existsSync(opensaasCli) &&
|
|
48
|
+
existsSync(coreDist) &&
|
|
49
|
+
existsSync(path.join(toolchainNodeModules, '.bin', 'opensaas')) &&
|
|
50
|
+
existsSync(path.join(toolchainNodeModules, '.bin', 'prisma')) &&
|
|
51
|
+
existsSync(path.join(toolchainNodeModules, '@prisma', 'adapter-better-sqlite3'))
|
|
52
|
+
|
|
53
|
+
/** Build the temp project's `opensaas.config.ts`: sqlite + a database-backed rate limiter. */
|
|
54
|
+
function makeConfigSource(window: number, max: number): string {
|
|
55
|
+
return `import { config } from '@opensaas/stack-core'
|
|
56
|
+
import { authPlugin } from '@opensaas/stack-auth'
|
|
57
|
+
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
|
|
58
|
+
|
|
59
|
+
export default config({
|
|
60
|
+
plugins: [
|
|
61
|
+
authPlugin({
|
|
62
|
+
emailAndPassword: { enabled: true },
|
|
63
|
+
rateLimit: { enabled: true, window: ${window}, max: ${max}, storage: 'database' },
|
|
64
|
+
}),
|
|
65
|
+
],
|
|
66
|
+
db: {
|
|
67
|
+
provider: 'sqlite',
|
|
68
|
+
prismaClientConstructor: (PrismaClient) => {
|
|
69
|
+
const adapter = new PrismaBetterSqlite3({ url: process.env.DATABASE_URL || './dev.db' })
|
|
70
|
+
return new PrismaClient({ adapter })
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
lists: {},
|
|
74
|
+
})
|
|
75
|
+
`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A GET request against a real (rate-limited) better-auth endpoint from a fixed "client". */
|
|
79
|
+
function sessionRequest(ip: string): Request {
|
|
80
|
+
return new Request('http://localhost:3000/api/auth/get-session', {
|
|
81
|
+
method: 'GET',
|
|
82
|
+
headers: { 'x-forwarded-for': ip },
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Scaffold a fresh temp project with the given window/max and push its schema. Returns the project dir. */
|
|
87
|
+
async function setupProject(window: number, max: number): Promise<string> {
|
|
88
|
+
const tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'opensaas-ratelimit-e2e-'))
|
|
89
|
+
const dir = path.join(tmpRoot, 'project')
|
|
90
|
+
await fsp.mkdir(dir, { recursive: true })
|
|
91
|
+
|
|
92
|
+
await fsp.writeFile(path.join(dir, 'opensaas.config.ts'), makeConfigSource(window, max))
|
|
93
|
+
await fsp.writeFile(
|
|
94
|
+
path.join(dir, 'package.json'),
|
|
95
|
+
JSON.stringify(
|
|
96
|
+
{ name: 'ratelimit-e2e-project', version: '0.0.0', private: true, type: 'module' },
|
|
97
|
+
null,
|
|
98
|
+
2,
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
await fsp.writeFile(path.join(dir, '.env'), 'DATABASE_URL=file:./dev.db\n')
|
|
102
|
+
await fsp.symlink(toolchainNodeModules, path.join(dir, 'node_modules'))
|
|
103
|
+
|
|
104
|
+
const env = {
|
|
105
|
+
...process.env,
|
|
106
|
+
DATABASE_URL: 'file:./dev.db',
|
|
107
|
+
BETTER_AUTH_SECRET: 'e2e-test-secret-not-for-production-0000000000',
|
|
108
|
+
BETTER_AUTH_URL: 'http://localhost:3000',
|
|
109
|
+
}
|
|
110
|
+
const binDir = path.join(dir, 'node_modules', '.bin')
|
|
111
|
+
await run(path.join(binDir, 'opensaas'), ['generate'], { cwd: dir, env })
|
|
112
|
+
await run(path.join(binDir, 'prisma'), ['db', 'push'], { cwd: dir, env })
|
|
113
|
+
|
|
114
|
+
return dir
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Import the temp project's config + generated context and build a real auth
|
|
119
|
+
* instance via `createAuth()`. Each call produces a genuinely separate
|
|
120
|
+
* `betterAuth()` instance — `createAuth()`'s lazy proxy calls `betterAuth()`
|
|
121
|
+
* fresh on first use inside its own closure (see `src/server/index.ts`), so
|
|
122
|
+
* two calls here are two independently-constructed `Auth` objects even when
|
|
123
|
+
* (as here) they resolve through the same imported `rawOpensaasContext`
|
|
124
|
+
* module — itself just the standard Prisma-connection-pooling shape a real
|
|
125
|
+
* app would also share across requests. What distinguishes this from
|
|
126
|
+
* in-memory rate-limit storage is that each `betterAuth()` instance keeps no
|
|
127
|
+
* limiter state of its own — every read/write round-trips through Prisma to
|
|
128
|
+
* the shared on-disk table, which is exactly the property under test.
|
|
129
|
+
*/
|
|
130
|
+
async function createAuthInstanceForProject(dir: string) {
|
|
131
|
+
// The config's `prismaClientConstructor` reads `process.env.DATABASE_URL` at
|
|
132
|
+
// runtime (unlike the `generate`/`db push` subprocesses, which received it
|
|
133
|
+
// via their own `env`). An absolute path avoids it resolving relative to
|
|
134
|
+
// the test process's cwd instead of the temp project dir.
|
|
135
|
+
process.env.DATABASE_URL = `file:${path.join(dir, 'dev.db')}`
|
|
136
|
+
|
|
137
|
+
// `@vite-ignore` suppresses Vite's static analysis of this computed,
|
|
138
|
+
// external (outside the package root) specifier — it isn't something Vite
|
|
139
|
+
// could usefully pre-bundle anyway.
|
|
140
|
+
const config = (
|
|
141
|
+
(await import(/* @vite-ignore */ pathToFileURL(path.join(dir, 'opensaas.config.ts')).href)) as {
|
|
142
|
+
default: OpenSaasConfig | Promise<OpenSaasConfig>
|
|
143
|
+
}
|
|
144
|
+
).default
|
|
145
|
+
const { rawOpensaasContext } = (await import(
|
|
146
|
+
/* @vite-ignore */ pathToFileURL(path.join(dir, '.opensaas/context.ts')).href
|
|
147
|
+
)) as { rawOpensaasContext: Promise<AccessContext> }
|
|
148
|
+
|
|
149
|
+
const resolvedContext = await rawOpensaasContext
|
|
150
|
+
|
|
151
|
+
return { auth: createAuth(config, resolvedContext), context: resolvedContext }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Unlink the borrowed node_modules symlink (never recurse into/delete the real one) and remove the temp root. */
|
|
155
|
+
async function cleanupProject(dir: string): Promise<void> {
|
|
156
|
+
// The generated `.opensaas/context.ts` caches its Prisma client on
|
|
157
|
+
// `globalThis.prisma` outside `NODE_ENV === 'production'` (the standard
|
|
158
|
+
// dev-mode HMR pattern, so a hot reload doesn't open a fresh connection
|
|
159
|
+
// every time). That's process-wide, not per-module — so the NEXT temp
|
|
160
|
+
// project created in this same test process would otherwise inherit THIS
|
|
161
|
+
// one's already-cached client, silently pointed at a temp dir this
|
|
162
|
+
// function is about to delete. Clear it so each project's own
|
|
163
|
+
// `prismaClientConstructor` runs fresh.
|
|
164
|
+
delete (globalThis as { prisma?: unknown }).prisma
|
|
165
|
+
|
|
166
|
+
const linkPath = path.join(dir, 'node_modules')
|
|
167
|
+
if (existsSync(linkPath)) {
|
|
168
|
+
await fsp.unlink(linkPath)
|
|
169
|
+
}
|
|
170
|
+
await fsp.rm(path.dirname(dir), { recursive: true, force: true })
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
describe.skipIf(!prerequisitesPresent)(
|
|
174
|
+
'database-backed rate limiter — live end-to-end (issue #909)',
|
|
175
|
+
() => {
|
|
176
|
+
it('rejects requests once the count exceeds max within the window, against a real generated RateLimit table', async () => {
|
|
177
|
+
const dir = await setupProject(60, 3)
|
|
178
|
+
try {
|
|
179
|
+
const { auth, context } = await createAuthInstanceForProject(dir)
|
|
180
|
+
const ip = '203.0.113.10'
|
|
181
|
+
|
|
182
|
+
const statuses: number[] = []
|
|
183
|
+
for (let i = 0; i < 5; i++) {
|
|
184
|
+
const res = await auth.handler(sessionRequest(ip))
|
|
185
|
+
statuses.push(res.status)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// First `max` (3) requests succeed, everything past it is rejected.
|
|
189
|
+
expect(statuses.slice(0, 3)).toEqual([200, 200, 200])
|
|
190
|
+
expect(statuses.slice(3)).toEqual([429, 429])
|
|
191
|
+
await context.prisma.$disconnect()
|
|
192
|
+
} finally {
|
|
193
|
+
await cleanupProject(dir)
|
|
194
|
+
}
|
|
195
|
+
}, 120_000)
|
|
196
|
+
|
|
197
|
+
it('persists the counter across two separately-constructed auth instances sharing the database', async () => {
|
|
198
|
+
const dir = await setupProject(60, 3)
|
|
199
|
+
try {
|
|
200
|
+
const ip = '198.51.100.20'
|
|
201
|
+
|
|
202
|
+
// Two independently-constructed betterAuth() instances (via two
|
|
203
|
+
// separate createAuth() lazy proxies), sharing one sqlite file —
|
|
204
|
+
// the property in-memory storage does not have.
|
|
205
|
+
const { auth: authA } = await createAuthInstanceForProject(dir)
|
|
206
|
+
const { auth: authB, context } = await createAuthInstanceForProject(dir)
|
|
207
|
+
|
|
208
|
+
expect((await authA.handler(sessionRequest(ip))).status).toBe(200)
|
|
209
|
+
expect((await authA.handler(sessionRequest(ip))).status).toBe(200)
|
|
210
|
+
expect((await authA.handler(sessionRequest(ip))).status).toBe(200)
|
|
211
|
+
|
|
212
|
+
// Instance B, constructed fresh and never having handled a request
|
|
213
|
+
// for this IP, must see A's persisted counter via the database and
|
|
214
|
+
// reject — proof the limiter state lives in the DB, not in-process.
|
|
215
|
+
expect((await authB.handler(sessionRequest(ip))).status).toBe(429)
|
|
216
|
+
await context.prisma.$disconnect()
|
|
217
|
+
} finally {
|
|
218
|
+
await cleanupProject(dir)
|
|
219
|
+
}
|
|
220
|
+
}, 120_000)
|
|
221
|
+
},
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
// Surface, in a normal unit run, why this guard was skipped.
|
|
225
|
+
describe.runIf(!prerequisitesPresent)('database-backed rate limiter e2e (skipped)', () => {
|
|
226
|
+
it('runs only in the e2e job (set RUN_RATE_LIMIT_E2E=1 after install + build)', () => {
|
|
227
|
+
expect(prerequisitesPresent).toBe(false)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
afterAll(() => {
|
|
231
|
+
if (!guardEnabled) return
|
|
232
|
+
// The guard is opted in but its build/toolchain prerequisites are
|
|
233
|
+
// missing — surface why, mirroring the scaffold guard's message.
|
|
234
|
+
console.warn(
|
|
235
|
+
'[rate-limit-e2e] RUN_RATE_LIMIT_E2E=1 was set but prerequisites are missing. ' +
|
|
236
|
+
'Run `pnpm install && pnpm build` (and ensure examples/starter-auth has been installed) first.',
|
|
237
|
+
)
|
|
238
|
+
})
|
|
239
|
+
})
|
|
@@ -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
|
@@ -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', () => {
|