@pikku/kysely 0.13.0 → 0.13.2

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.
@@ -0,0 +1,416 @@
1
+ import { describe, test, beforeEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { CamelCasePlugin, Kysely, SqliteDialect } from 'kysely'
4
+ import Database from 'better-sqlite3'
5
+ import { SerializePlugin } from './serialize-plugin.js'
6
+ import type { KyselyPikkuDB } from './kysely-tables.js'
7
+ import { KyselyScopeService } from './kysely-scope-service.js'
8
+
9
+ const SCOPES = [
10
+ { id: 'admin' },
11
+ { id: 'admin:invoices', description: 'Invoice management' },
12
+ { id: 'admin:invoices:create' },
13
+ { id: 'billing' },
14
+ { id: 'billing:read' },
15
+ ]
16
+
17
+ let db: Kysely<KyselyPikkuDB>
18
+ let service: KyselyScopeService
19
+
20
+ /**
21
+ * ScopeService FKs into better-auth's `user` table, which better-auth owns and
22
+ * migrates. Stand it up here so the cascade can be exercised.
23
+ */
24
+ const createUserTable = async (db: Kysely<any>) => {
25
+ await db.schema
26
+ .createTable('user')
27
+ .ifNotExists()
28
+ .addColumn('id', 'text', (col) => col.primaryKey())
29
+ .execute()
30
+ }
31
+
32
+ const addUser = async (id: string) => {
33
+ await (db as Kysely<any>).insertInto('user').values({ id }).execute()
34
+ }
35
+
36
+ beforeEach(async () => {
37
+ const database = new Database(':memory:')
38
+ database.pragma('foreign_keys = ON')
39
+ db = new Kysely<KyselyPikkuDB>({
40
+ dialect: new SqliteDialect({ database }),
41
+ plugins: [new CamelCasePlugin(), new SerializePlugin()],
42
+ })
43
+ await createUserTable(db)
44
+ service = new KyselyScopeService(db)
45
+ await service.init()
46
+ await service.syncScopes(SCOPES)
47
+ })
48
+
49
+ describe('KyselyScopeService — syncScopes', () => {
50
+ test('registers the declared scopes', async () => {
51
+ const rows = await db.selectFrom('pikkuScopes').select('name').execute()
52
+ assert.deepEqual(
53
+ rows.map((r) => r.name).sort(),
54
+ SCOPES.map((s) => s.id).sort()
55
+ )
56
+ })
57
+
58
+ test('is idempotent', async () => {
59
+ await service.syncScopes(SCOPES)
60
+ const rows = await db.selectFrom('pikkuScopes').select('name').execute()
61
+ assert.equal(rows.length, SCOPES.length)
62
+ })
63
+
64
+ test('adds newly declared scopes', async () => {
65
+ await service.syncScopes([...SCOPES, { id: 'reports' }])
66
+ const rows = await db.selectFrom('pikkuScopes').select('name').execute()
67
+ assert.ok(rows.some((r) => r.name === 'reports'))
68
+ })
69
+
70
+ // The decision that matters: an undeclared scope is inert, not revoked.
71
+ // Auto-deleting here would mean a rename or a rollback silently destroys
72
+ // grants, and during a rolling deploy the last replica to boot would win.
73
+ test('never deletes a scope that is no longer declared', async () => {
74
+ await service.syncScopes([{ id: 'admin' }])
75
+
76
+ const rows = await db.selectFrom('pikkuScopes').select('name').execute()
77
+ assert.ok(
78
+ rows.some((r) => r.name === 'billing:read'),
79
+ 'an undeclared scope must survive a sync'
80
+ )
81
+ })
82
+
83
+ test('does not revoke a grant when a scope stops being declared', async () => {
84
+ await service.createRole({ name: 'billing', scopes: ['billing:read'] })
85
+ await addUser('u1')
86
+ await service.addUserToRole('u1', 'billing')
87
+
88
+ await service.syncScopes([{ id: 'admin' }])
89
+
90
+ assert.deepEqual(await service.resolveScopes('u1'), ['billing:read'])
91
+ })
92
+
93
+ test('lists the declared vocabulary with descriptions', async () => {
94
+ const scopes = await service.listScopes()
95
+
96
+ assert.deepEqual(
97
+ scopes.map((s) => s.id).sort(),
98
+ SCOPES.map((s) => s.id).sort()
99
+ )
100
+ assert.equal(
101
+ scopes.find((s) => s.id === 'admin:invoices')!.description,
102
+ 'Invoice management'
103
+ )
104
+ assert.ok(scopes.every((s) => s.declared))
105
+ })
106
+
107
+ test('lists an undeclared scope as undeclared rather than hiding it', async () => {
108
+ await service.syncScopes([{ id: 'admin' }])
109
+
110
+ const scopes = await service.listScopes()
111
+ const billing = scopes.find((s) => s.id === 'billing:read')
112
+
113
+ assert.ok(billing, 'an undeclared scope is still in the vocabulary')
114
+ assert.equal(billing!.declared, false)
115
+ })
116
+
117
+ test('updates a description in place', async () => {
118
+ await service.syncScopes([{ id: 'admin', description: 'Changed' }])
119
+ const row = await db
120
+ .selectFrom('pikkuScopes')
121
+ .select('description')
122
+ .where('name', '=', 'admin')
123
+ .executeTakeFirst()
124
+ assert.equal(row!.description, 'Changed')
125
+ })
126
+ })
127
+
128
+ describe('KyselyScopeService — roles', () => {
129
+ test('creates a role with scopes', async () => {
130
+ await service.createRole({
131
+ name: 'invoicing',
132
+ description: 'Invoice staff',
133
+ scopes: ['admin:invoices:create'],
134
+ })
135
+
136
+ const roles = await service.listRoles()
137
+ assert.equal(roles.length, 1)
138
+ assert.equal(roles[0]!.name, 'invoicing')
139
+ assert.equal(roles[0]!.description, 'Invoice staff')
140
+ assert.deepEqual(roles[0]!.scopes, ['admin:invoices:create'])
141
+ })
142
+
143
+ // The FK's real value: the database itself refuses an undeclared grant, so
144
+ // the console role editor cannot write one.
145
+ test('refuses to grant an undeclared scope', async () => {
146
+ await assert.rejects(
147
+ () => service.createRole({ name: 'bogus', scopes: ['nope:nothere'] }),
148
+ 'the FK to pikku_scopes must reject an undeclared scope'
149
+ )
150
+ })
151
+
152
+ test("replaces a role's scopes", async () => {
153
+ await service.createRole({ name: 'r', scopes: ['billing:read'] })
154
+ await service.setRoleScopes('r', ['admin:invoices:create'])
155
+
156
+ const roles = await service.listRoles()
157
+ assert.deepEqual(roles[0]!.scopes, ['admin:invoices:create'])
158
+ })
159
+
160
+ test('deleting a role cascades its scope rows away', async () => {
161
+ await service.createRole({ name: 'r', scopes: ['billing:read'] })
162
+ await service.deleteRole('r')
163
+
164
+ const rows = await db.selectFrom('pikkuRoleScopes').selectAll().execute()
165
+ assert.equal(rows.length, 0)
166
+ })
167
+ })
168
+
169
+ describe('KyselyScopeService — user grants', () => {
170
+ beforeEach(async () => {
171
+ await service.createRole({ name: 'billing', scopes: ['billing:read'] })
172
+ await service.createRole({
173
+ name: 'invoicing',
174
+ scopes: ['admin:invoices:create', 'admin:invoices'],
175
+ })
176
+ await addUser('u1')
177
+ })
178
+
179
+ test('resolves nothing for a user with no roles', async () => {
180
+ await addUser('u2')
181
+ assert.deepEqual(await service.resolveScopes('u2'), [])
182
+ })
183
+
184
+ test('resolves the scopes of a granted role', async () => {
185
+ await service.addUserToRole('u1', 'billing')
186
+ assert.deepEqual(await service.resolveScopes('u1'), ['billing:read'])
187
+ })
188
+
189
+ test('unions scopes across several roles', async () => {
190
+ await service.addUserToRole('u1', 'billing')
191
+ await service.addUserToRole('u1', 'invoicing')
192
+
193
+ assert.deepEqual((await service.resolveScopes('u1')).sort(), [
194
+ 'admin:invoices',
195
+ 'admin:invoices:create',
196
+ 'billing:read',
197
+ ])
198
+ })
199
+
200
+ test('deduplicates a scope shared by two roles', async () => {
201
+ await service.createRole({ name: 'other', scopes: ['billing:read'] })
202
+ await service.addUserToRole('u1', 'billing')
203
+ await service.addUserToRole('u1', 'other')
204
+
205
+ assert.deepEqual(await service.resolveScopes('u1'), ['billing:read'])
206
+ })
207
+
208
+ test('granting the same role twice is idempotent', async () => {
209
+ await service.addUserToRole('u1', 'billing')
210
+ await service.addUserToRole('u1', 'billing')
211
+
212
+ assert.deepEqual(await service.listUserRoles('u1'), ['billing'])
213
+ })
214
+
215
+ test('removes a user from a role', async () => {
216
+ await service.addUserToRole('u1', 'billing')
217
+ await service.removeUserFromRole('u1', 'billing')
218
+
219
+ assert.deepEqual(await service.resolveScopes('u1'), [])
220
+ })
221
+
222
+ test('records who granted the role', async () => {
223
+ await service.addUserToRole('u1', 'billing', 'admin-user')
224
+
225
+ const row = await db
226
+ .selectFrom('pikkuUserRole')
227
+ .select('grantedBy')
228
+ .where('userId', '=', 'u1')
229
+ .executeTakeFirst()
230
+ assert.equal(row!.grantedBy, 'admin-user')
231
+ })
232
+
233
+ test('refuses to grant a role that does not exist', async () => {
234
+ await assert.rejects(() => service.addUserToRole('u1', 'ghost'))
235
+ })
236
+
237
+ test('deleting a role cascades the user grant away', async () => {
238
+ await service.addUserToRole('u1', 'billing')
239
+ await service.deleteRole('billing')
240
+
241
+ assert.deepEqual(await service.listUserRoles('u1'), [])
242
+ })
243
+
244
+ test('deleting a user cascades their role grants away', async () => {
245
+ await service.addUserToRole('u1', 'billing')
246
+
247
+ await (db as Kysely<any>)
248
+ .deleteFrom('user')
249
+ .where('id', '=', 'u1')
250
+ .execute()
251
+
252
+ const rows = await db.selectFrom('pikkuUserRole').selectAll().execute()
253
+ assert.equal(rows.length, 0, 'better-auth owns users; grants must follow')
254
+ })
255
+ })
256
+
257
+ describe('KyselyScopeService — direct user scopes', () => {
258
+ beforeEach(async () => {
259
+ await service.createRole({ name: 'billing', scopes: ['billing:read'] })
260
+ await addUser('u1')
261
+ })
262
+
263
+ test('resolves a scope granted directly to a user', async () => {
264
+ await service.addScopeToUser('u1', 'billing:read')
265
+ assert.deepEqual(await service.resolveScopes('u1'), ['billing:read'])
266
+ })
267
+
268
+ test('unions direct grants with role-derived scopes', async () => {
269
+ await service.addUserToRole('u1', 'billing')
270
+ await service.addScopeToUser('u1', 'admin:invoices:create')
271
+
272
+ assert.deepEqual((await service.resolveScopes('u1')).sort(), [
273
+ 'admin:invoices:create',
274
+ 'billing:read',
275
+ ])
276
+ })
277
+
278
+ test('deduplicates a scope held both directly and via a role', async () => {
279
+ await service.addUserToRole('u1', 'billing')
280
+ await service.addScopeToUser('u1', 'billing:read')
281
+
282
+ assert.deepEqual(await service.resolveScopes('u1'), ['billing:read'])
283
+ })
284
+
285
+ test('lists only the directly granted scopes, not role-derived ones', async () => {
286
+ await service.addUserToRole('u1', 'billing')
287
+ await service.addScopeToUser('u1', 'admin:invoices:create')
288
+
289
+ assert.deepEqual(await service.listUserScopes('u1'), [
290
+ 'admin:invoices:create',
291
+ ])
292
+ })
293
+
294
+ test('removing a direct grant leaves a role-derived scope intact', async () => {
295
+ await service.addUserToRole('u1', 'billing')
296
+ await service.addScopeToUser('u1', 'admin:invoices:create')
297
+ await service.removeScopeFromUser('u1', 'admin:invoices:create')
298
+
299
+ assert.deepEqual(await service.resolveScopes('u1'), ['billing:read'])
300
+ })
301
+
302
+ test('granting the same scope twice is idempotent', async () => {
303
+ await service.addScopeToUser('u1', 'billing:read')
304
+ await service.addScopeToUser('u1', 'billing:read')
305
+
306
+ assert.deepEqual(await service.listUserScopes('u1'), ['billing:read'])
307
+ })
308
+
309
+ // Same FK guarantee as roles: the database refuses a direct grant of a scope
310
+ // that was never declared, so the console cannot write one.
311
+ test('refuses to grant an undeclared scope directly', async () => {
312
+ await assert.rejects(
313
+ () => service.addScopeToUser('u1', 'nope:nothere'),
314
+ 'the FK to pikku_scopes must reject an undeclared direct grant'
315
+ )
316
+ })
317
+
318
+ test('records who granted the scope', async () => {
319
+ await service.addScopeToUser('u1', 'billing:read', 'admin-user')
320
+
321
+ const row = await db
322
+ .selectFrom('pikkuUserScope')
323
+ .select('grantedBy')
324
+ .where('userId', '=', 'u1')
325
+ .executeTakeFirst()
326
+ assert.equal(row!.grantedBy, 'admin-user')
327
+ })
328
+
329
+ test('deleting a user cascades their direct scope grants away', async () => {
330
+ await service.addScopeToUser('u1', 'billing:read')
331
+
332
+ await (db as Kysely<any>)
333
+ .deleteFrom('user')
334
+ .where('id', '=', 'u1')
335
+ .execute()
336
+
337
+ const rows = await db.selectFrom('pikkuUserScope').selectAll().execute()
338
+ assert.equal(rows.length, 0, 'better-auth owns users; grants must follow')
339
+ })
340
+
341
+ test('pruning an undeclared scope cascades a direct grant away', async () => {
342
+ await service.addScopeToUser('u1', 'billing:read')
343
+ await service.syncScopes([{ id: 'admin' }])
344
+ await service.pruneScopes()
345
+
346
+ assert.deepEqual(await service.resolveScopes('u1'), [])
347
+ })
348
+ })
349
+
350
+ describe('KyselyScopeService — audit and prune', () => {
351
+ beforeEach(async () => {
352
+ await service.createRole({ name: 'billing', scopes: ['billing:read'] })
353
+ await addUser('u1')
354
+ await service.addUserToRole('u1', 'billing')
355
+ })
356
+
357
+ test('reports nothing stale when everything is declared', async () => {
358
+ assert.deepEqual(await service.findStaleScopes(), [])
359
+ })
360
+
361
+ test('reports a stale scope and the roles holding it', async () => {
362
+ await service.syncScopes([{ id: 'admin' }])
363
+
364
+ const stale = await service.findStaleScopes()
365
+ const billing = stale.find((s) => s.scope === 'billing:read')
366
+ assert.ok(billing, 'billing:read is no longer declared')
367
+ assert.deepEqual(billing!.roles, ['billing'])
368
+ })
369
+
370
+ test('prune removes stale scopes and reports them', async () => {
371
+ await service.syncScopes([{ id: 'admin' }])
372
+
373
+ const pruned = await service.pruneScopes()
374
+
375
+ assert.ok(pruned.includes('billing:read'))
376
+ const rows = await db.selectFrom('pikkuScopes').select('name').execute()
377
+ assert.ok(!rows.some((r) => r.name === 'billing:read'))
378
+ })
379
+
380
+ test('prune cascades the stale scope out of its roles', async () => {
381
+ await service.syncScopes([{ id: 'admin' }])
382
+ await service.pruneScopes()
383
+
384
+ assert.deepEqual(
385
+ await service.resolveScopes('u1'),
386
+ [],
387
+ 'pruning is the deliberate revocation path'
388
+ )
389
+ })
390
+
391
+ test('prune leaves declared scopes alone', async () => {
392
+ await service.pruneScopes()
393
+
394
+ assert.deepEqual(await service.resolveScopes('u1'), ['billing:read'])
395
+ })
396
+
397
+ test('prune ignores a scope a concurrent sync redeclared between select and delete', async () => {
398
+ await service.syncScopes([{ id: 'admin' }])
399
+
400
+ const racy = new (class extends KyselyScopeService {
401
+ async findStaleScopes() {
402
+ const stale = await super.findStaleScopes()
403
+ await this.syncScopes([{ id: 'admin' }, { id: 'billing:read' }])
404
+ return stale
405
+ }
406
+ })(db)
407
+
408
+ await racy.pruneScopes()
409
+
410
+ assert.deepEqual(
411
+ await service.resolveScopes('u1'),
412
+ ['billing:read'],
413
+ 'a scope redeclared before the delete must keep its grants'
414
+ )
415
+ })
416
+ })