@stonecrop/casl-middleware 0.7.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.
Files changed (66) hide show
  1. package/README.md +149 -0
  2. package/dist/casl-middleware.d.ts +158 -0
  3. package/dist/casl-middleware.js +40571 -0
  4. package/dist/casl-middleware.js.map +1 -0
  5. package/dist/casl_middleware.tsbuildinfo +1 -0
  6. package/dist/src/index.d.ts +6 -0
  7. package/dist/src/index.d.ts.map +1 -0
  8. package/dist/src/index.js +3 -0
  9. package/dist/src/middleware/ability.d.ts +55 -0
  10. package/dist/src/middleware/ability.d.ts.map +1 -0
  11. package/dist/src/middleware/ability.js +139 -0
  12. package/dist/src/middleware/graphql.d.ts +11 -0
  13. package/dist/src/middleware/graphql.d.ts.map +1 -0
  14. package/dist/src/middleware/graphql.js +120 -0
  15. package/dist/src/middleware/introspection.d.ts +71 -0
  16. package/dist/src/middleware/introspection.d.ts.map +1 -0
  17. package/dist/src/middleware/introspection.js +169 -0
  18. package/dist/src/middleware/jwt.d.ts +114 -0
  19. package/dist/src/middleware/jwt.d.ts.map +1 -0
  20. package/dist/src/middleware/jwt.js +291 -0
  21. package/dist/src/middleware/postgraphile.d.ts +7 -0
  22. package/dist/src/middleware/postgraphile.d.ts.map +1 -0
  23. package/dist/src/middleware/postgraphile.js +80 -0
  24. package/dist/src/middleware/yoga.d.ts +15 -0
  25. package/dist/src/middleware/yoga.d.ts.map +1 -0
  26. package/dist/src/middleware/yoga.js +32 -0
  27. package/dist/src/tsdoc-metadata.json +11 -0
  28. package/dist/src/types/index.d.ts +114 -0
  29. package/dist/src/types/index.d.ts.map +1 -0
  30. package/dist/src/types/index.js +0 -0
  31. package/dist/tests/ability.test.d.ts +2 -0
  32. package/dist/tests/ability.test.d.ts.map +1 -0
  33. package/dist/tests/ability.test.js +125 -0
  34. package/dist/tests/helpers/test-utils.d.ts +46 -0
  35. package/dist/tests/helpers/test-utils.d.ts.map +1 -0
  36. package/dist/tests/helpers/test-utils.js +92 -0
  37. package/dist/tests/introspection.test.d.ts +2 -0
  38. package/dist/tests/introspection.test.d.ts.map +1 -0
  39. package/dist/tests/introspection.test.js +368 -0
  40. package/dist/tests/jwt.test.d.ts +2 -0
  41. package/dist/tests/jwt.test.d.ts.map +1 -0
  42. package/dist/tests/jwt.test.js +371 -0
  43. package/dist/tests/middleware.test.d.ts +2 -0
  44. package/dist/tests/middleware.test.d.ts.map +1 -0
  45. package/dist/tests/middleware.test.js +184 -0
  46. package/dist/tests/postgraphile-plugin.test.d.ts +2 -0
  47. package/dist/tests/postgraphile-plugin.test.d.ts.map +1 -0
  48. package/dist/tests/postgraphile-plugin.test.js +56 -0
  49. package/dist/tests/setup.d.ts +2 -0
  50. package/dist/tests/setup.d.ts.map +1 -0
  51. package/dist/tests/setup.js +11 -0
  52. package/dist/tests/user-roles.test.d.ts +2 -0
  53. package/dist/tests/user-roles.test.d.ts.map +1 -0
  54. package/dist/tests/user-roles.test.js +157 -0
  55. package/dist/tests/yoga-plugin.test.d.ts +2 -0
  56. package/dist/tests/yoga-plugin.test.d.ts.map +1 -0
  57. package/dist/tests/yoga-plugin.test.js +47 -0
  58. package/package.json +91 -0
  59. package/src/index.ts +15 -0
  60. package/src/middleware/ability.ts +191 -0
  61. package/src/middleware/graphql.ts +157 -0
  62. package/src/middleware/introspection.ts +258 -0
  63. package/src/middleware/jwt.ts +394 -0
  64. package/src/middleware/postgraphile.ts +93 -0
  65. package/src/middleware/yoga.ts +39 -0
  66. package/src/types/index.ts +133 -0
@@ -0,0 +1,371 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import jwt from 'jsonwebtoken';
3
+ import { createJWTMiddleware, createJWT, createHTTPJWTMiddleware, refreshTokenUtils, } from '../src/middleware/jwt';
4
+ describe('JWT Middleware', () => {
5
+ const TEST_SECRET = 'test-secret-key-for-testing';
6
+ const TEST_USER = {
7
+ id: 'user-123',
8
+ roles: ['user', 'editor'],
9
+ };
10
+ describe('createJWT', () => {
11
+ it('should create a valid JWT token', () => {
12
+ const token = createJWT(TEST_USER, {
13
+ secret: TEST_SECRET,
14
+ expiresIn: '1h',
15
+ });
16
+ expect(token).toBeDefined();
17
+ expect(token.split('.')).toHaveLength(3);
18
+ // Verify the token
19
+ const decoded = jwt.verify(token, TEST_SECRET);
20
+ expect(decoded.sub).toBe(TEST_USER.id);
21
+ expect(decoded.roles).toEqual(TEST_USER.roles);
22
+ });
23
+ it('should include additional claims', () => {
24
+ const token = createJWT(TEST_USER, {
25
+ secret: TEST_SECRET,
26
+ additionalClaims: {
27
+ email: 'user@example.com',
28
+ permissions: ['read:posts', 'write:posts'],
29
+ },
30
+ });
31
+ const decoded = jwt.verify(token, TEST_SECRET);
32
+ expect(decoded.email).toBe('user@example.com');
33
+ expect(decoded.permissions).toEqual(['read:posts', 'write:posts']);
34
+ });
35
+ it('should set issuer and audience', () => {
36
+ const token = createJWT(TEST_USER, {
37
+ secret: TEST_SECRET,
38
+ issuer: 'test-app',
39
+ audience: 'api.example.com',
40
+ });
41
+ const decoded = jwt.verify(token, TEST_SECRET, {
42
+ issuer: 'test-app',
43
+ audience: 'api.example.com',
44
+ });
45
+ expect(decoded.iss).toBe('test-app');
46
+ expect(decoded.aud).toBe('api.example.com');
47
+ });
48
+ });
49
+ describe('createJWTMiddleware', () => {
50
+ let mockContext;
51
+ let mockNext;
52
+ beforeEach(() => {
53
+ mockContext = {
54
+ req: {
55
+ headers: {
56
+ get: vi.fn(),
57
+ },
58
+ },
59
+ user: undefined,
60
+ };
61
+ mockNext = vi.fn().mockResolvedValue('next-result');
62
+ });
63
+ it('should extract and verify valid JWT token', async () => {
64
+ const token = createJWT(TEST_USER, { secret: TEST_SECRET });
65
+ const middleware = createJWTMiddleware({
66
+ secret: TEST_SECRET,
67
+ });
68
+ mockContext.req.headers.get = vi.fn().mockReturnValue(`Bearer ${token}`);
69
+ await middleware(mockContext, mockNext);
70
+ expect(mockContext.user).toBeDefined();
71
+ expect(mockContext.user?.id).toBe(TEST_USER.id);
72
+ expect(mockContext.user?.roles).toEqual(TEST_USER.roles);
73
+ expect(mockNext).toHaveBeenCalled();
74
+ });
75
+ it('should handle token without Bearer prefix', async () => {
76
+ const token = createJWT(TEST_USER, { secret: TEST_SECRET });
77
+ const middleware = createJWTMiddleware({
78
+ secret: TEST_SECRET,
79
+ });
80
+ mockContext.req.headers.get = vi.fn().mockReturnValue(token);
81
+ await middleware(mockContext, mockNext);
82
+ expect(mockContext.user?.id).toBe(TEST_USER.id);
83
+ expect(mockNext).toHaveBeenCalled();
84
+ });
85
+ it('should throw error for invalid token', async () => {
86
+ const middleware = createJWTMiddleware({
87
+ secret: TEST_SECRET,
88
+ });
89
+ mockContext.req.headers.get = vi.fn().mockReturnValue('Bearer invalid-token');
90
+ await expect(middleware(mockContext, mockNext)).rejects.toThrow('Invalid token');
91
+ expect(mockNext).not.toHaveBeenCalled();
92
+ });
93
+ it('should throw error for expired token', async () => {
94
+ const token = jwt.sign({ sub: TEST_USER.id, roles: TEST_USER.roles }, TEST_SECRET, { expiresIn: '-1h' } // Already expired
95
+ );
96
+ const middleware = createJWTMiddleware({
97
+ secret: TEST_SECRET,
98
+ });
99
+ mockContext.req.headers.get = vi.fn().mockReturnValue(`Bearer ${token}`);
100
+ await expect(middleware(mockContext, mockNext)).rejects.toThrow('Token has expired');
101
+ expect(mockNext).not.toHaveBeenCalled();
102
+ });
103
+ it('should handle optional mode when no token is present', async () => {
104
+ const middleware = createJWTMiddleware({
105
+ secret: TEST_SECRET,
106
+ optional: true,
107
+ });
108
+ mockContext.req.headers.get = vi.fn().mockReturnValue(null);
109
+ await middleware(mockContext, mockNext);
110
+ expect(mockContext.user).toBeUndefined();
111
+ expect(mockNext).toHaveBeenCalled();
112
+ });
113
+ it('should handle optional mode with invalid token', async () => {
114
+ const middleware = createJWTMiddleware({
115
+ secret: TEST_SECRET,
116
+ optional: true,
117
+ });
118
+ mockContext.req.headers.get = vi.fn().mockReturnValue('Bearer invalid-token');
119
+ // Should not throw in optional mode
120
+ await middleware(mockContext, mockNext);
121
+ expect(mockContext.user).toBeUndefined();
122
+ expect(mockNext).toHaveBeenCalled();
123
+ });
124
+ it('should skip when disabled', async () => {
125
+ const middleware = createJWTMiddleware({
126
+ enabled: false,
127
+ secret: TEST_SECRET,
128
+ });
129
+ await middleware(mockContext, mockNext);
130
+ expect(mockContext.user).toBeUndefined();
131
+ expect(mockNext).toHaveBeenCalled();
132
+ expect(mockContext.req.headers.get).not.toHaveBeenCalled();
133
+ });
134
+ it('should use custom user extractor', async () => {
135
+ const customUser = {
136
+ id: 'custom-id',
137
+ roles: ['custom-role'],
138
+ customField: 'custom-value',
139
+ };
140
+ const token = jwt.sign({ user_id: 'custom-id', user_roles: ['custom-role'], extra: 'custom-value' }, TEST_SECRET);
141
+ const middleware = createJWTMiddleware({
142
+ secret: TEST_SECRET,
143
+ extractUser: payload => ({
144
+ id: payload.user_id,
145
+ roles: payload.user_roles,
146
+ customField: payload.extra,
147
+ }),
148
+ });
149
+ mockContext.req.headers.get = vi.fn().mockReturnValue(`Bearer ${token}`);
150
+ await middleware(mockContext, mockNext);
151
+ expect(mockContext.user).toEqual(customUser);
152
+ });
153
+ it('should verify with specific algorithms', async () => {
154
+ const token = jwt.sign({ sub: TEST_USER.id, roles: TEST_USER.roles }, TEST_SECRET, { algorithm: 'HS256' });
155
+ const middleware = createJWTMiddleware({
156
+ secret: TEST_SECRET,
157
+ algorithms: ['HS256'],
158
+ });
159
+ mockContext.req.headers.get = vi.fn().mockReturnValue(`Bearer ${token}`);
160
+ await middleware(mockContext, mockNext);
161
+ expect(mockContext.user?.id).toBe(TEST_USER.id);
162
+ });
163
+ it('should verify issuer and audience', async () => {
164
+ const token = jwt.sign({ sub: TEST_USER.id, roles: TEST_USER.roles }, TEST_SECRET, {
165
+ issuer: 'test-app',
166
+ audience: 'api.example.com',
167
+ });
168
+ const middleware = createJWTMiddleware({
169
+ secret: TEST_SECRET,
170
+ issuer: 'test-app',
171
+ audience: 'api.example.com',
172
+ });
173
+ mockContext.req.headers.get = vi.fn().mockReturnValue(`Bearer ${token}`);
174
+ await middleware(mockContext, mockNext);
175
+ expect(mockContext.user?.id).toBe(TEST_USER.id);
176
+ });
177
+ it('should store JWT payload in context', async () => {
178
+ const token = createJWT(TEST_USER, {
179
+ secret: TEST_SECRET,
180
+ additionalClaims: { extra: 'data' },
181
+ });
182
+ const middleware = createJWTMiddleware({
183
+ secret: TEST_SECRET,
184
+ });
185
+ mockContext.req.headers.get = vi.fn().mockReturnValue(`Bearer ${token}`);
186
+ await middleware(mockContext, mockNext);
187
+ expect(mockContext.jwtPayload).toBeDefined();
188
+ expect(mockContext.jwtPayload.extra).toBe('data');
189
+ });
190
+ });
191
+ describe('createHTTPJWTMiddleware', () => {
192
+ it('should work with Express-style middleware', async () => {
193
+ const token = createJWT(TEST_USER, { secret: TEST_SECRET });
194
+ const middleware = createHTTPJWTMiddleware({
195
+ secret: TEST_SECRET,
196
+ });
197
+ const req = {
198
+ headers: {
199
+ authorization: `Bearer ${token}`,
200
+ },
201
+ user: undefined,
202
+ };
203
+ const res = {
204
+ status: vi.fn().mockReturnThis(),
205
+ json: vi.fn(),
206
+ };
207
+ const next = vi.fn();
208
+ await middleware(req, res, next);
209
+ expect(req.user).toBeDefined();
210
+ expect(req.user?.id).toBe(TEST_USER.id);
211
+ expect(next).toHaveBeenCalled();
212
+ expect(res.status).not.toHaveBeenCalled();
213
+ });
214
+ it('should return 401 for invalid token', async () => {
215
+ const middleware = createHTTPJWTMiddleware({
216
+ secret: TEST_SECRET,
217
+ });
218
+ const req = {
219
+ headers: {
220
+ authorization: 'Bearer invalid-token',
221
+ },
222
+ };
223
+ const res = {
224
+ status: vi.fn().mockReturnThis(),
225
+ json: vi.fn(),
226
+ };
227
+ const next = vi.fn();
228
+ await middleware(req, res, next);
229
+ expect(res.status).toHaveBeenCalledWith(401);
230
+ expect(res.json).toHaveBeenCalledWith({
231
+ error: 'Invalid token',
232
+ code: 'UNAUTHORIZED',
233
+ });
234
+ expect(next).not.toHaveBeenCalled();
235
+ });
236
+ it('should continue in optional mode without token', async () => {
237
+ const middleware = createHTTPJWTMiddleware({
238
+ secret: TEST_SECRET,
239
+ optional: true,
240
+ });
241
+ const req = {
242
+ headers: {},
243
+ user: undefined,
244
+ };
245
+ const res = {
246
+ status: vi.fn().mockReturnThis(),
247
+ json: vi.fn(),
248
+ };
249
+ const next = vi.fn();
250
+ await middleware(req, res, next);
251
+ expect(req.user).toBeUndefined();
252
+ expect(next).toHaveBeenCalled();
253
+ expect(res.status).not.toHaveBeenCalled();
254
+ });
255
+ });
256
+ describe('refreshTokenUtils', () => {
257
+ it('should create access and refresh token pair', () => {
258
+ const { accessToken, refreshToken } = refreshTokenUtils.createTokenPair(TEST_USER, {
259
+ accessSecret: TEST_SECRET,
260
+ refreshSecret: TEST_SECRET + '-refresh',
261
+ });
262
+ expect(accessToken).toBeDefined();
263
+ expect(refreshToken).toBeDefined();
264
+ // Verify access token
265
+ const accessPayload = jwt.verify(accessToken, TEST_SECRET);
266
+ expect(accessPayload.sub).toBe(TEST_USER.id);
267
+ expect(accessPayload.type).toBe('access');
268
+ // Verify refresh token
269
+ const refreshPayload = jwt.verify(refreshToken, TEST_SECRET + '-refresh');
270
+ expect(refreshPayload.sub).toBe(TEST_USER.id);
271
+ expect(refreshPayload.type).toBe('refresh');
272
+ });
273
+ it('should refresh access token with valid refresh token', async () => {
274
+ const refreshToken = jwt.sign({ sub: TEST_USER.id, type: 'refresh' }, TEST_SECRET + '-refresh', {
275
+ expiresIn: '7d',
276
+ });
277
+ const mockGetUserById = vi.fn().mockResolvedValue(TEST_USER);
278
+ const result = await refreshTokenUtils.refreshAccessToken(refreshToken, {
279
+ accessSecret: TEST_SECRET,
280
+ refreshSecret: TEST_SECRET + '-refresh',
281
+ getUserById: mockGetUserById,
282
+ });
283
+ expect(result.accessToken).toBeDefined();
284
+ expect(result.user).toEqual(TEST_USER);
285
+ expect(mockGetUserById).toHaveBeenCalledWith(TEST_USER.id);
286
+ // Verify new access token
287
+ const decoded = jwt.verify(result.accessToken, TEST_SECRET);
288
+ expect(decoded.sub).toBe(TEST_USER.id);
289
+ expect(decoded.type).toBe('access');
290
+ });
291
+ it('should reject invalid refresh token type', async () => {
292
+ // Use access token as refresh token (wrong type)
293
+ const wrongToken = jwt.sign({ sub: TEST_USER.id, type: 'access' }, TEST_SECRET + '-refresh');
294
+ const mockGetUserById = vi.fn();
295
+ await expect(refreshTokenUtils.refreshAccessToken(wrongToken, {
296
+ accessSecret: TEST_SECRET,
297
+ refreshSecret: TEST_SECRET + '-refresh',
298
+ getUserById: mockGetUserById,
299
+ })).rejects.toThrow('Invalid token type');
300
+ expect(mockGetUserById).not.toHaveBeenCalled();
301
+ });
302
+ it('should reject expired refresh token', async () => {
303
+ const expiredToken = jwt.sign({ sub: TEST_USER.id, type: 'refresh' }, TEST_SECRET + '-refresh', {
304
+ expiresIn: '-1h',
305
+ });
306
+ const mockGetUserById = vi.fn();
307
+ await expect(refreshTokenUtils.refreshAccessToken(expiredToken, {
308
+ accessSecret: TEST_SECRET,
309
+ refreshSecret: TEST_SECRET + '-refresh',
310
+ getUserById: mockGetUserById,
311
+ })).rejects.toThrow('Refresh token expired');
312
+ });
313
+ it('should reject if user not found', async () => {
314
+ const refreshToken = jwt.sign({ sub: 'non-existent', type: 'refresh' }, TEST_SECRET + '-refresh');
315
+ const mockGetUserById = vi.fn().mockResolvedValue(null);
316
+ await expect(refreshTokenUtils.refreshAccessToken(refreshToken, {
317
+ accessSecret: TEST_SECRET,
318
+ refreshSecret: TEST_SECRET + '-refresh',
319
+ getUserById: mockGetUserById,
320
+ })).rejects.toThrow('User not found');
321
+ });
322
+ });
323
+ describe('JWT with permissions', () => {
324
+ it('should handle JWT with embedded permissions', async () => {
325
+ const tokenWithPermissions = jwt.sign({
326
+ sub: TEST_USER.id,
327
+ roles: TEST_USER.roles,
328
+ permissions: [
329
+ { action: 'create', subject: 'Post' },
330
+ { action: 'update', subject: 'Post', conditions: { authorId: TEST_USER.id } },
331
+ ],
332
+ }, TEST_SECRET);
333
+ const middleware = createJWTMiddleware({
334
+ secret: TEST_SECRET,
335
+ });
336
+ const mockContext = {
337
+ req: {
338
+ headers: {
339
+ get: vi.fn().mockReturnValue(`Bearer ${tokenWithPermissions}`),
340
+ },
341
+ },
342
+ user: undefined,
343
+ };
344
+ await middleware(mockContext, vi.fn());
345
+ expect(mockContext.user).toBeDefined();
346
+ expect(mockContext.user.permissions).toHaveLength(2);
347
+ expect(mockContext.user.permissions[0]).toEqual({
348
+ action: 'create',
349
+ subject: 'Post',
350
+ });
351
+ });
352
+ });
353
+ });
354
+ describe('JWT Configuration Validation', () => {
355
+ it('should throw error if neither secret nor publicKey is provided', () => {
356
+ expect(() => {
357
+ createJWTMiddleware({
358
+ enabled: true,
359
+ // No secret or publicKey
360
+ });
361
+ }).toThrow('JWT middleware requires either secret or publicKey');
362
+ });
363
+ it('should not throw when disabled without secret', () => {
364
+ expect(() => {
365
+ createJWTMiddleware({
366
+ enabled: false,
367
+ // No secret, but disabled
368
+ });
369
+ }).not.toThrow();
370
+ });
371
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=middleware.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"middleware.test.d.ts","sourceRoot":"","sources":["../../tests/middleware.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,184 @@
1
+ import { PureAbility, AbilityBuilder } from '@casl/ability';
2
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
3
+ import { createCaslMiddleware } from '../src/middleware/graphql';
4
+ const Ability = PureAbility;
5
+ // Helper to create abilities with proper matchers
6
+ const createTestAbility = (rules) => {
7
+ const { can, cannot, build } = new AbilityBuilder(Ability);
8
+ rules.forEach(rule => {
9
+ if (rule.inverted) {
10
+ if (rule.fields && rule.conditions) {
11
+ cannot(rule.action, rule.subject, rule.fields, rule.conditions);
12
+ }
13
+ else if (rule.fields) {
14
+ cannot(rule.action, rule.subject, rule.fields);
15
+ }
16
+ else if (rule.conditions) {
17
+ cannot(rule.action, rule.subject, rule.conditions);
18
+ }
19
+ else {
20
+ cannot(rule.action, rule.subject);
21
+ }
22
+ }
23
+ else {
24
+ if (rule.fields && rule.conditions) {
25
+ can(rule.action, rule.subject, rule.fields, rule.conditions);
26
+ }
27
+ else if (rule.fields) {
28
+ can(rule.action, rule.subject, rule.fields);
29
+ }
30
+ else if (rule.conditions) {
31
+ can(rule.action, rule.subject, rule.conditions);
32
+ }
33
+ else {
34
+ can(rule.action, rule.subject);
35
+ }
36
+ }
37
+ });
38
+ return build({
39
+ detectSubjectType: (object) => object?.type || object?.constructor?.name || 'Unknown',
40
+ fieldMatcher: fields => field => {
41
+ if (!fields || !field)
42
+ return false;
43
+ return Array.isArray(fields) ? fields.includes(field) : fields === field;
44
+ },
45
+ conditionsMatcher: conditions => object => {
46
+ if (!conditions || !object)
47
+ return true;
48
+ return Object.keys(conditions).every(key => object[key] === conditions[key]);
49
+ },
50
+ });
51
+ };
52
+ describe('CASL GraphQL Middleware', () => {
53
+ let mockResolve;
54
+ let mockContext;
55
+ let mockInfo;
56
+ beforeEach(() => {
57
+ mockResolve = vi.fn().mockResolvedValue({ data: 'test' });
58
+ mockContext = {
59
+ ability: createTestAbility([
60
+ { action: 'read', subject: 'Query' },
61
+ { action: 'read', subject: 'User' },
62
+ { action: 'update', subject: 'User', conditions: { id: '123' } },
63
+ ]),
64
+ user: { id: '123', roles: ['user'] },
65
+ };
66
+ mockInfo = {
67
+ fieldName: 'getUser',
68
+ parentType: { name: 'Query' },
69
+ path: { typename: 'Query', key: 'getUser' },
70
+ operation: {
71
+ operation: 'query',
72
+ loc: { source: { body: 'query { getUser { id } }' } },
73
+ },
74
+ };
75
+ });
76
+ describe('Basic Middleware Functionality', () => {
77
+ it('should pass through when ability allows the operation', async () => {
78
+ const middleware = createCaslMiddleware();
79
+ const result = await middleware(mockResolve, {}, {}, mockContext, mockInfo);
80
+ expect(mockResolve).toHaveBeenCalled();
81
+ expect(result).toEqual({ data: 'test' });
82
+ });
83
+ it('should create ability when not in context', async () => {
84
+ const middleware = createCaslMiddleware();
85
+ const contextWithoutAbility = { user: { id: '123', roles: ['user'] } };
86
+ // The middleware creates an ability if none exists
87
+ const result = await middleware(mockResolve, {}, {}, contextWithoutAbility, mockInfo);
88
+ expect(mockResolve).toHaveBeenCalled();
89
+ expect(result).toEqual({ data: 'test' });
90
+ expect(contextWithoutAbility.ability).toBeDefined();
91
+ });
92
+ it('should deny access when ability does not allow operation', async () => {
93
+ const middleware = createCaslMiddleware();
94
+ mockContext.ability = createTestAbility([{ action: 'read', subject: 'Post' }]);
95
+ await expect(middleware(mockResolve, {}, {}, mockContext, mockInfo)).rejects.toThrow('Access denied for read on Query');
96
+ });
97
+ });
98
+ describe('Subject Mapping', () => {
99
+ it('should map GraphQL types to custom subjects', async () => {
100
+ const middleware = createCaslMiddleware({
101
+ subjectMap: {
102
+ Query: 'query_operations',
103
+ User: 'app_user',
104
+ },
105
+ });
106
+ mockContext.ability = createTestAbility([{ action: 'read', subject: 'query_operations' }]);
107
+ const result = await middleware(mockResolve, {}, {}, mockContext, mockInfo);
108
+ expect(mockResolve).toHaveBeenCalled();
109
+ expect(result).toEqual({ data: 'test' });
110
+ });
111
+ it('should use original type name when no mapping exists', async () => {
112
+ const middleware = createCaslMiddleware({
113
+ subjectMap: { User: 'app_user' },
114
+ });
115
+ // Should work with original 'Query' subject since it's not mapped
116
+ const result = await middleware(mockResolve, {}, {}, mockContext, mockInfo);
117
+ expect(mockResolve).toHaveBeenCalled();
118
+ });
119
+ });
120
+ describe('Action Mapping', () => {
121
+ it('should map GraphQL operations to custom actions', async () => {
122
+ const middleware = createCaslMiddleware({
123
+ actionMap: {
124
+ query: 'view',
125
+ mutation: 'modify',
126
+ subscription: 'watch',
127
+ },
128
+ });
129
+ mockContext.ability = createTestAbility([{ action: 'view', subject: 'Query' }]);
130
+ const result = await middleware(mockResolve, {}, {}, mockContext, mockInfo);
131
+ expect(mockResolve).toHaveBeenCalled();
132
+ });
133
+ });
134
+ describe('Field-Level Permissions', () => {
135
+ it('should check field-level permissions when configured', async () => {
136
+ const middleware = createCaslMiddleware({
137
+ fieldPermissions: {
138
+ 'User.email': [{ action: 'read', subject: 'UserEmail' }],
139
+ 'User.password': [{ action: 'read', subject: 'UserPassword' }],
140
+ },
141
+ });
142
+ mockInfo.parentType = { name: 'User' };
143
+ mockInfo.fieldName = 'email';
144
+ mockInfo.path = { typename: 'User', key: 'email' };
145
+ mockContext.ability = createTestAbility([
146
+ { action: 'read', subject: 'User' },
147
+ { action: 'read', subject: 'UserEmail' },
148
+ ]);
149
+ const result = await middleware(mockResolve, {}, {}, mockContext, mockInfo);
150
+ expect(mockResolve).toHaveBeenCalled();
151
+ });
152
+ it('should deny access to fields without permission', async () => {
153
+ const middleware = createCaslMiddleware({
154
+ fieldPermissions: {
155
+ 'User.password': [{ action: 'read', subject: 'UserPassword' }],
156
+ },
157
+ });
158
+ mockInfo.parentType = { name: 'User' };
159
+ mockInfo.fieldName = 'password';
160
+ mockInfo.path = { typename: 'User', key: 'password' };
161
+ mockContext.ability = createTestAbility([{ action: 'read', subject: 'User' }]);
162
+ await expect(middleware(mockResolve, {}, {}, mockContext, mockInfo)).rejects.toThrow('Access denied for field User.password');
163
+ });
164
+ });
165
+ describe('Mutation Field Checking', () => {
166
+ it('should check field permissions for mutations with input', async () => {
167
+ const middleware = createCaslMiddleware();
168
+ mockInfo.parentType = { name: 'Mutation' };
169
+ mockInfo.operation.operation = 'mutation';
170
+ mockInfo.operation.loc = {
171
+ source: {
172
+ body: 'mutation { updateUser(input: { name: "test", role: "admin" }) { id } }',
173
+ },
174
+ };
175
+ mockContext.ability = createTestAbility([
176
+ { action: 'update', subject: 'Mutation' },
177
+ { action: 'update', subject: 'User', fields: ['name'] },
178
+ ]);
179
+ const args = { input: { name: 'test', role: 'admin' } };
180
+ const result = await middleware(mockResolve, {}, args, mockContext, mockInfo);
181
+ expect(mockResolve).toHaveBeenCalled();
182
+ });
183
+ });
184
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=postgraphile-plugin.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"postgraphile-plugin.test.d.ts","sourceRoot":"","sources":["../../tests/postgraphile-plugin.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,56 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { pglCaslPlugin } from '../src';
3
+ import { createConfigBasedAbilityBuilder } from '../src/middleware/ability';
4
+ describe('PostGraphile CASL Plugins', () => {
5
+ describe('pglCaslPlugin (extendSchema)', () => {
6
+ it('should be a valid extendSchema plugin object', () => {
7
+ expect(pglCaslPlugin).toBeDefined();
8
+ expect(pglCaslPlugin).toBeInstanceOf(Object); // It's an object, not a function
9
+ expect(pglCaslPlugin).toHaveProperty('name');
10
+ expect(pglCaslPlugin).toHaveProperty('version');
11
+ });
12
+ it('should have schema extension properties', () => {
13
+ // The plugin object has specific properties
14
+ expect(pglCaslPlugin).toHaveProperty('name');
15
+ expect(pglCaslPlugin.name).toMatch(/^ExtendSchemaPlugin_/);
16
+ // Check for schema extension function
17
+ expect(pglCaslPlugin).toHaveProperty('schema');
18
+ expect(typeof pglCaslPlugin.schema).toBe('object');
19
+ // Check for hooks if they exist
20
+ if (pglCaslPlugin.schema?.hooks) {
21
+ expect(pglCaslPlugin.schema.hooks).toHaveProperty('build');
22
+ }
23
+ });
24
+ it('should define schema extensions when executed', () => {
25
+ // Since extendSchema creates a plugin object, we need to test differently
26
+ // The actual schema extension happens when PostGraphile processes the plugin
27
+ // We can verify the plugin has the expected structure
28
+ expect(pglCaslPlugin).toHaveProperty('name');
29
+ expect(pglCaslPlugin).toHaveProperty('version');
30
+ // The schema extension function is wrapped inside the plugin
31
+ // and will be executed by PostGraphile
32
+ });
33
+ });
34
+ describe('Ability Integration', () => {
35
+ it('should create abilities with custom builder', async () => {
36
+ const customBuilder = createConfigBasedAbilityBuilder({
37
+ defaultRules: [{ action: 'read', subject: 'Query' }],
38
+ roles: {
39
+ editor: [
40
+ { action: 'create', subject: 'Post' },
41
+ { action: 'update', subject: 'Post' },
42
+ ],
43
+ },
44
+ });
45
+ // Test that the ability builder works
46
+ const ability = await customBuilder({ id: '123', roles: ['editor'] });
47
+ expect(ability.can('create', 'Post')).toBe(true);
48
+ expect(ability.can('update', 'Post')).toBe(true);
49
+ });
50
+ it('should work with PostGraphile plugin system', () => {
51
+ // Test that our plugin has the correct shape for PostGraphile
52
+ expect(pglCaslPlugin).toHaveProperty('name');
53
+ expect(pglCaslPlugin).toHaveProperty('version');
54
+ });
55
+ });
56
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=setup.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"setup.d.ts","sourceRoot":"","sources":["../../tests/setup.ts"],"names":[],"mappings":""}
@@ -0,0 +1,11 @@
1
+ import { beforeAll, afterEach, afterAll } from 'vitest';
2
+ // Setup test environment
3
+ beforeAll(() => {
4
+ // Add any global setup here
5
+ });
6
+ afterEach(() => {
7
+ // Clean up after each test
8
+ });
9
+ afterAll(() => {
10
+ // Clean up after all tests
11
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=user-roles.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"user-roles.test.d.ts","sourceRoot":"","sources":["../../tests/user-roles.test.ts"],"names":[],"mappings":""}