@opensaas/stack-core 0.1.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 +4 -0
- package/README.md +447 -0
- package/dist/access/engine.d.ts +73 -0
- package/dist/access/engine.d.ts.map +1 -0
- package/dist/access/engine.js +244 -0
- package/dist/access/engine.js.map +1 -0
- package/dist/access/field-transforms.d.ts +47 -0
- package/dist/access/field-transforms.d.ts.map +1 -0
- package/dist/access/field-transforms.js +2 -0
- package/dist/access/field-transforms.js.map +1 -0
- package/dist/access/index.d.ts +3 -0
- package/dist/access/index.d.ts.map +1 -0
- package/dist/access/index.js +2 -0
- package/dist/access/index.js.map +1 -0
- package/dist/access/types.d.ts +83 -0
- package/dist/access/types.d.ts.map +1 -0
- package/dist/access/types.js +2 -0
- package/dist/access/types.js.map +1 -0
- package/dist/config/index.d.ts +39 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +38 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/types.d.ts +413 -0
- package/dist/config/types.d.ts.map +1 -0
- package/dist/config/types.js +2 -0
- package/dist/config/types.js.map +1 -0
- package/dist/context/index.d.ts +31 -0
- package/dist/context/index.d.ts.map +1 -0
- package/dist/context/index.js +524 -0
- package/dist/context/index.js.map +1 -0
- package/dist/context/nested-operations.d.ts +10 -0
- package/dist/context/nested-operations.d.ts.map +1 -0
- package/dist/context/nested-operations.js +261 -0
- package/dist/context/nested-operations.js.map +1 -0
- package/dist/fields/index.d.ts +78 -0
- package/dist/fields/index.d.ts.map +1 -0
- package/dist/fields/index.js +381 -0
- package/dist/fields/index.js.map +1 -0
- package/dist/hooks/index.d.ts +58 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/index.js +79 -0
- package/dist/hooks/index.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/case-utils.d.ts +49 -0
- package/dist/lib/case-utils.d.ts.map +1 -0
- package/dist/lib/case-utils.js +68 -0
- package/dist/lib/case-utils.js.map +1 -0
- package/dist/lib/case-utils.test.d.ts +2 -0
- package/dist/lib/case-utils.test.d.ts.map +1 -0
- package/dist/lib/case-utils.test.js +101 -0
- package/dist/lib/case-utils.test.js.map +1 -0
- package/dist/utils/password.d.ts +81 -0
- package/dist/utils/password.d.ts.map +1 -0
- package/dist/utils/password.js +132 -0
- package/dist/utils/password.js.map +1 -0
- package/dist/validation/schema.d.ts +17 -0
- package/dist/validation/schema.d.ts.map +1 -0
- package/dist/validation/schema.js +42 -0
- package/dist/validation/schema.js.map +1 -0
- package/dist/validation/schema.test.d.ts +2 -0
- package/dist/validation/schema.test.d.ts.map +1 -0
- package/dist/validation/schema.test.js +143 -0
- package/dist/validation/schema.test.js.map +1 -0
- package/docs/type-distribution-fix.md +136 -0
- package/package.json +48 -0
- package/src/access/engine.ts +360 -0
- package/src/access/field-transforms.ts +99 -0
- package/src/access/index.ts +20 -0
- package/src/access/types.ts +103 -0
- package/src/config/index.ts +71 -0
- package/src/config/types.ts +478 -0
- package/src/context/index.ts +814 -0
- package/src/context/nested-operations.ts +412 -0
- package/src/fields/index.ts +438 -0
- package/src/hooks/index.ts +132 -0
- package/src/index.ts +62 -0
- package/src/lib/case-utils.test.ts +127 -0
- package/src/lib/case-utils.ts +74 -0
- package/src/utils/password.ts +147 -0
- package/src/validation/schema.test.ts +171 -0
- package/src/validation/schema.ts +59 -0
- package/tests/access-relationships.test.ts +613 -0
- package/tests/access.test.ts +499 -0
- package/tests/config.test.ts +195 -0
- package/tests/context.test.ts +248 -0
- package/tests/hooks.test.ts +417 -0
- package/tests/password-type-distribution.test.ts +155 -0
- package/tests/password-types.test.ts +147 -0
- package/tests/password.test.ts +249 -0
- package/tsconfig.json +12 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.config.ts +27 -0
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
checkAccess,
|
|
4
|
+
mergeFilters,
|
|
5
|
+
checkFieldAccess,
|
|
6
|
+
filterReadableFields,
|
|
7
|
+
filterWritableFields,
|
|
8
|
+
isBoolean,
|
|
9
|
+
isPrismaFilter,
|
|
10
|
+
} from '../src/access/engine.js'
|
|
11
|
+
import type { AccessControl, FieldAccess, AccessContext } from '../src/access/types.js'
|
|
12
|
+
|
|
13
|
+
describe('Access Control', () => {
|
|
14
|
+
const mockContext: AccessContext = {
|
|
15
|
+
session: null,
|
|
16
|
+
prisma: {},
|
|
17
|
+
db: {},
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('isBoolean', () => {
|
|
21
|
+
it('should return true for boolean values', () => {
|
|
22
|
+
expect(isBoolean(true)).toBe(true)
|
|
23
|
+
expect(isBoolean(false)).toBe(true)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('should return false for non-boolean values', () => {
|
|
27
|
+
expect(isBoolean(1)).toBe(false)
|
|
28
|
+
expect(isBoolean('true')).toBe(false)
|
|
29
|
+
expect(isBoolean({})).toBe(false)
|
|
30
|
+
expect(isBoolean(null)).toBe(false)
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('isPrismaFilter', () => {
|
|
35
|
+
it('should return true for object filters', () => {
|
|
36
|
+
expect(isPrismaFilter({ id: '123' })).toBe(true)
|
|
37
|
+
expect(isPrismaFilter({ name: { equals: 'John' } })).toBe(true)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('should return false for non-filter values', () => {
|
|
41
|
+
expect(isPrismaFilter(true)).toBe(false)
|
|
42
|
+
expect(isPrismaFilter(false)).toBe(false)
|
|
43
|
+
expect(isPrismaFilter(null)).toBe(false)
|
|
44
|
+
expect(isPrismaFilter([])).toBe(false)
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('checkAccess', () => {
|
|
49
|
+
it('should return false when no access control is defined', async () => {
|
|
50
|
+
const result = await checkAccess(undefined, {
|
|
51
|
+
session: null,
|
|
52
|
+
context: mockContext,
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
expect(result).toBe(false)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('should return true when access control allows', async () => {
|
|
59
|
+
const accessControl: AccessControl = vi.fn(async () => true)
|
|
60
|
+
|
|
61
|
+
const result = await checkAccess(accessControl, {
|
|
62
|
+
session: null,
|
|
63
|
+
context: mockContext,
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
expect(result).toBe(true)
|
|
67
|
+
expect(accessControl).toHaveBeenCalledWith({
|
|
68
|
+
session: null,
|
|
69
|
+
context: mockContext,
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('should return false when access control denies', async () => {
|
|
74
|
+
const accessControl: AccessControl = vi.fn(async () => false)
|
|
75
|
+
|
|
76
|
+
const result = await checkAccess(accessControl, {
|
|
77
|
+
session: null,
|
|
78
|
+
context: mockContext,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
expect(result).toBe(false)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('should return filter when access control returns a filter', async () => {
|
|
85
|
+
const filter = { userId: '123' }
|
|
86
|
+
const accessControl: AccessControl = vi.fn(async () => filter)
|
|
87
|
+
|
|
88
|
+
const result = await checkAccess(accessControl, {
|
|
89
|
+
session: { userId: '123' },
|
|
90
|
+
context: mockContext,
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
expect(result).toEqual(filter)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('should pass item to access control when provided', async () => {
|
|
97
|
+
const item = { id: '1', name: 'Test' }
|
|
98
|
+
const accessControl: AccessControl = vi.fn(async () => true)
|
|
99
|
+
|
|
100
|
+
await checkAccess(accessControl, {
|
|
101
|
+
session: null,
|
|
102
|
+
item,
|
|
103
|
+
context: mockContext,
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
expect(accessControl).toHaveBeenCalledWith({
|
|
107
|
+
session: null,
|
|
108
|
+
item,
|
|
109
|
+
context: mockContext,
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe('mergeFilters', () => {
|
|
115
|
+
it('should return null when access is denied', () => {
|
|
116
|
+
const result = mergeFilters({ id: '123' }, false)
|
|
117
|
+
expect(result).toBeNull()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('should return user filter when access is fully granted', () => {
|
|
121
|
+
const userFilter = { name: 'John' }
|
|
122
|
+
const result = mergeFilters(userFilter, true)
|
|
123
|
+
expect(result).toEqual(userFilter)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('should return empty object when access is granted and no user filter', () => {
|
|
127
|
+
const result = mergeFilters(undefined, true)
|
|
128
|
+
expect(result).toEqual({})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('should return access filter when no user filter', () => {
|
|
132
|
+
const accessFilter = { userId: '123' }
|
|
133
|
+
const result = mergeFilters(undefined, accessFilter)
|
|
134
|
+
expect(result).toEqual(accessFilter)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('should combine filters with AND when both exist', () => {
|
|
138
|
+
const userFilter = { name: 'John' }
|
|
139
|
+
const accessFilter = { userId: '123' }
|
|
140
|
+
const result = mergeFilters(userFilter, accessFilter)
|
|
141
|
+
|
|
142
|
+
expect(result).toEqual({
|
|
143
|
+
AND: [accessFilter, userFilter],
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe('checkFieldAccess', () => {
|
|
149
|
+
it('should allow access when no field access is defined', async () => {
|
|
150
|
+
const result = await checkFieldAccess(undefined, 'read', {
|
|
151
|
+
session: null,
|
|
152
|
+
context: mockContext,
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
expect(result).toBe(true)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('should allow access when no specific operation access is defined', async () => {
|
|
159
|
+
const fieldAccess: FieldAccess = {}
|
|
160
|
+
|
|
161
|
+
const result = await checkFieldAccess(fieldAccess, 'read', {
|
|
162
|
+
session: null,
|
|
163
|
+
context: mockContext,
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
expect(result).toBe(true)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('should deny access when operation returns false', async () => {
|
|
170
|
+
const fieldAccess: FieldAccess = {
|
|
171
|
+
read: vi.fn(async () => false),
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const result = await checkFieldAccess(fieldAccess, 'read', {
|
|
175
|
+
session: null,
|
|
176
|
+
context: mockContext,
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
expect(result).toBe(false)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('should allow access when operation returns true', async () => {
|
|
183
|
+
const fieldAccess: FieldAccess = {
|
|
184
|
+
read: vi.fn(async () => true),
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const result = await checkFieldAccess(fieldAccess, 'read', {
|
|
188
|
+
session: null,
|
|
189
|
+
context: mockContext,
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
expect(result).toBe(true)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('should check filter match when operation returns filter', async () => {
|
|
196
|
+
const item = { userId: '123' }
|
|
197
|
+
const fieldAccess: FieldAccess = {
|
|
198
|
+
read: vi.fn(async () => ({ userId: '123' })),
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const result = await checkFieldAccess(fieldAccess, 'read', {
|
|
202
|
+
session: null,
|
|
203
|
+
item,
|
|
204
|
+
context: mockContext,
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
expect(result).toBe(true)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('should deny access when filter does not match', async () => {
|
|
211
|
+
const item = { userId: '456' }
|
|
212
|
+
const fieldAccess: FieldAccess = {
|
|
213
|
+
read: vi.fn(async () => ({ userId: '123' })),
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const result = await checkFieldAccess(fieldAccess, 'read', {
|
|
217
|
+
session: null,
|
|
218
|
+
item,
|
|
219
|
+
context: mockContext,
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
expect(result).toBe(false)
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('should work with equals condition', async () => {
|
|
226
|
+
const item = { status: 'active' }
|
|
227
|
+
const fieldAccess: FieldAccess = {
|
|
228
|
+
read: vi.fn(async () => ({ status: { equals: 'active' } })),
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const result = await checkFieldAccess(fieldAccess, 'read', {
|
|
232
|
+
session: null,
|
|
233
|
+
item,
|
|
234
|
+
context: mockContext,
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
expect(result).toBe(true)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('should work with not condition', async () => {
|
|
241
|
+
const item = { status: 'active' }
|
|
242
|
+
const fieldAccess: FieldAccess = {
|
|
243
|
+
read: vi.fn(async () => ({ status: { not: 'deleted' } })),
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const result = await checkFieldAccess(fieldAccess, 'read', {
|
|
247
|
+
session: null,
|
|
248
|
+
item,
|
|
249
|
+
context: mockContext,
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
expect(result).toBe(true)
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
describe('filterReadableFields', () => {
|
|
257
|
+
it('should include all fields when no access control', async () => {
|
|
258
|
+
const item = {
|
|
259
|
+
id: '1',
|
|
260
|
+
name: 'John',
|
|
261
|
+
email: 'john@example.com',
|
|
262
|
+
createdAt: new Date(),
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const fieldConfigs = {
|
|
266
|
+
name: { type: 'text' },
|
|
267
|
+
email: { type: 'text' },
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const result = await filterReadableFields(item, fieldConfigs, {
|
|
271
|
+
session: null,
|
|
272
|
+
context: mockContext,
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
expect(result).toEqual(item)
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('should always include system fields', async () => {
|
|
279
|
+
const item = {
|
|
280
|
+
id: '1',
|
|
281
|
+
createdAt: new Date(),
|
|
282
|
+
updatedAt: new Date(),
|
|
283
|
+
name: 'John',
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const fieldConfigs = {
|
|
287
|
+
name: {
|
|
288
|
+
access: {
|
|
289
|
+
read: async () => false,
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const result = await filterReadableFields(item, fieldConfigs, {
|
|
295
|
+
session: null,
|
|
296
|
+
context: mockContext,
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
expect(result.id).toBe('1')
|
|
300
|
+
expect(result.createdAt).toBeDefined()
|
|
301
|
+
expect(result.updatedAt).toBeDefined()
|
|
302
|
+
expect(result.name).toBeUndefined()
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it('should filter out fields with denied read access', async () => {
|
|
306
|
+
const item = {
|
|
307
|
+
id: '1',
|
|
308
|
+
name: 'John',
|
|
309
|
+
email: 'john@example.com',
|
|
310
|
+
password: 'hashed',
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const fieldConfigs = {
|
|
314
|
+
name: { type: 'text' },
|
|
315
|
+
email: { type: 'text' },
|
|
316
|
+
password: {
|
|
317
|
+
type: 'password',
|
|
318
|
+
access: {
|
|
319
|
+
read: async () => false,
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const result = await filterReadableFields(item, fieldConfigs, {
|
|
325
|
+
session: null,
|
|
326
|
+
context: mockContext,
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
expect(result.name).toBe('John')
|
|
330
|
+
expect(result.email).toBe('john@example.com')
|
|
331
|
+
expect(result.password).toBeUndefined()
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it('should respect session in field access', async () => {
|
|
335
|
+
const item = {
|
|
336
|
+
id: '1',
|
|
337
|
+
name: 'John',
|
|
338
|
+
salary: 100000,
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const fieldConfigs = {
|
|
342
|
+
name: { type: 'text' },
|
|
343
|
+
salary: {
|
|
344
|
+
type: 'integer',
|
|
345
|
+
access: {
|
|
346
|
+
read: async ({ session }) => session?.role === 'admin',
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Without admin session
|
|
352
|
+
const resultNoAccess = await filterReadableFields(item, fieldConfigs, {
|
|
353
|
+
session: { role: 'user' },
|
|
354
|
+
context: mockContext,
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
expect(resultNoAccess.salary).toBeUndefined()
|
|
358
|
+
|
|
359
|
+
// With admin session
|
|
360
|
+
const resultWithAccess = await filterReadableFields(item, fieldConfigs, {
|
|
361
|
+
session: { role: 'admin' },
|
|
362
|
+
context: mockContext,
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
expect(resultWithAccess.salary).toBe(100000)
|
|
366
|
+
})
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
describe('filterWritableFields', () => {
|
|
370
|
+
it('should include all fields when no access control', async () => {
|
|
371
|
+
const data = {
|
|
372
|
+
name: 'John',
|
|
373
|
+
email: 'john@example.com',
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const fieldConfigs = {
|
|
377
|
+
name: { type: 'text' },
|
|
378
|
+
email: { type: 'text' },
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const result = await filterWritableFields(data, fieldConfigs, 'create', {
|
|
382
|
+
session: null,
|
|
383
|
+
context: mockContext,
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
expect(result).toEqual(data)
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
it('should skip system fields', async () => {
|
|
390
|
+
const data = {
|
|
391
|
+
id: '1',
|
|
392
|
+
createdAt: new Date(),
|
|
393
|
+
updatedAt: new Date(),
|
|
394
|
+
name: 'John',
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const fieldConfigs = {
|
|
398
|
+
name: { type: 'text' },
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const result = await filterWritableFields(data, fieldConfigs, 'create', {
|
|
402
|
+
session: null,
|
|
403
|
+
context: mockContext,
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
expect(result.id).toBeUndefined()
|
|
407
|
+
expect(result.createdAt).toBeUndefined()
|
|
408
|
+
expect(result.updatedAt).toBeUndefined()
|
|
409
|
+
expect(result.name).toBe('John')
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
it('should filter out fields with denied write access', async () => {
|
|
413
|
+
const data = {
|
|
414
|
+
name: 'John',
|
|
415
|
+
email: 'john@example.com',
|
|
416
|
+
role: 'admin',
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const fieldConfigs = {
|
|
420
|
+
name: { type: 'text' },
|
|
421
|
+
email: { type: 'text' },
|
|
422
|
+
role: {
|
|
423
|
+
type: 'select',
|
|
424
|
+
access: {
|
|
425
|
+
create: async () => false,
|
|
426
|
+
},
|
|
427
|
+
},
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const result = await filterWritableFields(data, fieldConfigs, 'create', {
|
|
431
|
+
session: null,
|
|
432
|
+
context: mockContext,
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
expect(result.name).toBe('John')
|
|
436
|
+
expect(result.email).toBe('john@example.com')
|
|
437
|
+
expect(result.role).toBeUndefined()
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
it('should respect different access for create vs update', async () => {
|
|
441
|
+
const data = {
|
|
442
|
+
email: 'john@example.com',
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const fieldConfigs = {
|
|
446
|
+
email: {
|
|
447
|
+
type: 'text',
|
|
448
|
+
access: {
|
|
449
|
+
create: async () => true,
|
|
450
|
+
update: async () => false,
|
|
451
|
+
},
|
|
452
|
+
},
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Allow on create
|
|
456
|
+
const createResult = await filterWritableFields(data, fieldConfigs, 'create', {
|
|
457
|
+
session: null,
|
|
458
|
+
context: mockContext,
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
expect(createResult.email).toBe('john@example.com')
|
|
462
|
+
|
|
463
|
+
// Deny on update
|
|
464
|
+
const updateResult = await filterWritableFields(data, fieldConfigs, 'update', {
|
|
465
|
+
session: null,
|
|
466
|
+
context: mockContext,
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
expect(updateResult.email).toBeUndefined()
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
it('should pass item to field access on update', async () => {
|
|
473
|
+
const data = { name: 'Updated Name' }
|
|
474
|
+
const item = { id: '1', name: 'Original', userId: '123' }
|
|
475
|
+
const accessFn = vi.fn(async () => true)
|
|
476
|
+
|
|
477
|
+
const fieldConfigs = {
|
|
478
|
+
name: {
|
|
479
|
+
type: 'text',
|
|
480
|
+
access: {
|
|
481
|
+
update: accessFn,
|
|
482
|
+
},
|
|
483
|
+
},
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
await filterWritableFields(data, fieldConfigs, 'update', {
|
|
487
|
+
session: { userId: '123' },
|
|
488
|
+
item,
|
|
489
|
+
context: mockContext,
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
expect(accessFn).toHaveBeenCalledWith({
|
|
493
|
+
session: { userId: '123' },
|
|
494
|
+
item,
|
|
495
|
+
context: mockContext,
|
|
496
|
+
})
|
|
497
|
+
})
|
|
498
|
+
})
|
|
499
|
+
})
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { config, list } from '../src/config/index.js'
|
|
3
|
+
import type { OpenSaasConfig, ListConfig } from '../src/config/types.js'
|
|
4
|
+
import { select } from '../src/fields/index.js'
|
|
5
|
+
|
|
6
|
+
describe('config helpers', () => {
|
|
7
|
+
describe('config', () => {
|
|
8
|
+
it('should return the same config object', () => {
|
|
9
|
+
const testConfig: OpenSaasConfig = {
|
|
10
|
+
db: {
|
|
11
|
+
provider: 'postgresql',
|
|
12
|
+
url: 'postgresql://localhost:5432/test',
|
|
13
|
+
},
|
|
14
|
+
lists: {
|
|
15
|
+
User: {
|
|
16
|
+
fields: {
|
|
17
|
+
name: { type: 'text' },
|
|
18
|
+
email: { type: 'text', isIndexed: 'unique' },
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const result = config(testConfig)
|
|
25
|
+
expect(result).toBe(testConfig)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('should provide type safety for config', () => {
|
|
29
|
+
const testConfig = config({
|
|
30
|
+
db: {
|
|
31
|
+
provider: 'postgresql',
|
|
32
|
+
url: 'postgresql://localhost:5432/test',
|
|
33
|
+
},
|
|
34
|
+
lists: {
|
|
35
|
+
User: {
|
|
36
|
+
fields: {
|
|
37
|
+
name: { type: 'text' },
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
expect(testConfig.db.provider).toBe('postgresql')
|
|
44
|
+
expect(testConfig.lists.User).toBeDefined()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('should support optional session config', () => {
|
|
48
|
+
const testConfig = config({
|
|
49
|
+
db: {
|
|
50
|
+
provider: 'postgresql',
|
|
51
|
+
url: 'postgresql://localhost:5432/test',
|
|
52
|
+
},
|
|
53
|
+
lists: {},
|
|
54
|
+
session: {
|
|
55
|
+
getSession: async () => ({ userId: '123' }),
|
|
56
|
+
},
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
expect(testConfig.session).toBeDefined()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('should support optional ui config', () => {
|
|
63
|
+
const testConfig = config({
|
|
64
|
+
db: {
|
|
65
|
+
provider: 'postgresql',
|
|
66
|
+
url: 'postgresql://localhost:5432/test',
|
|
67
|
+
},
|
|
68
|
+
lists: {},
|
|
69
|
+
ui: {
|
|
70
|
+
basePath: '/admin',
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
expect(testConfig.ui?.basePath).toBe('/admin')
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe('list', () => {
|
|
79
|
+
it('should return the same list config', () => {
|
|
80
|
+
const testList: ListConfig = {
|
|
81
|
+
fields: {
|
|
82
|
+
name: { type: 'text' },
|
|
83
|
+
age: { type: 'integer' },
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const result = list(testList)
|
|
88
|
+
expect(result).toBe(testList)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('should support text fields', () => {
|
|
92
|
+
const testList = list({
|
|
93
|
+
fields: {
|
|
94
|
+
title: {
|
|
95
|
+
type: 'text',
|
|
96
|
+
validation: {
|
|
97
|
+
isRequired: true,
|
|
98
|
+
length: { min: 3, max: 100 },
|
|
99
|
+
},
|
|
100
|
+
isIndexed: 'unique',
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
expect(testList.fields.title.type).toBe('text')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('should support integer fields', () => {
|
|
109
|
+
const testList = list({
|
|
110
|
+
fields: {
|
|
111
|
+
count: {
|
|
112
|
+
type: 'integer',
|
|
113
|
+
validation: {
|
|
114
|
+
min: 0,
|
|
115
|
+
max: 100,
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
expect(testList.fields.count.type).toBe('integer')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('should support checkbox fields', () => {
|
|
125
|
+
const testList = list({
|
|
126
|
+
fields: {
|
|
127
|
+
isActive: { type: 'checkbox' },
|
|
128
|
+
},
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
expect(testList.fields.isActive.type).toBe('checkbox')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('should support select fields', () => {
|
|
135
|
+
const testList = list({
|
|
136
|
+
fields: {
|
|
137
|
+
status: select({
|
|
138
|
+
options: [
|
|
139
|
+
{ label: 'Draft', value: 'draft' },
|
|
140
|
+
{ label: 'Published', value: 'published' },
|
|
141
|
+
],
|
|
142
|
+
}),
|
|
143
|
+
},
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
expect(testList.fields.status.type).toBe('select')
|
|
147
|
+
expect(testList.fields.status.options).toHaveLength(2)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('should support relationship fields', () => {
|
|
151
|
+
const testList = list({
|
|
152
|
+
fields: {
|
|
153
|
+
author: {
|
|
154
|
+
type: 'relationship',
|
|
155
|
+
ref: 'User.posts',
|
|
156
|
+
many: false,
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
expect(testList.fields.author.type).toBe('relationship')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('should support access control', () => {
|
|
165
|
+
const testList = list({
|
|
166
|
+
fields: { name: { type: 'text' } },
|
|
167
|
+
access: {
|
|
168
|
+
operation: {
|
|
169
|
+
query: () => true,
|
|
170
|
+
create: () => true,
|
|
171
|
+
update: () => false,
|
|
172
|
+
delete: () => false,
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
expect(testList.access?.operation).toBeDefined()
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('should support hooks', () => {
|
|
181
|
+
const testList = list({
|
|
182
|
+
fields: { name: { type: 'text' } },
|
|
183
|
+
hooks: {
|
|
184
|
+
resolveInput: async ({ resolvedData }) => resolvedData,
|
|
185
|
+
validateInput: async () => {},
|
|
186
|
+
beforeOperation: async () => {},
|
|
187
|
+
afterOperation: async () => {},
|
|
188
|
+
},
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
expect(testList.hooks).toBeDefined()
|
|
192
|
+
expect(testList.hooks?.resolveInput).toBeDefined()
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
})
|