@stonecrop/casl-middleware 0.12.7 → 0.13.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 (40) hide show
  1. package/dist/index.js +3 -0
  2. package/dist/middleware/ability.js +139 -0
  3. package/dist/middleware/graphql.js +120 -0
  4. package/dist/middleware/introspection.js +169 -0
  5. package/dist/middleware/jwt.js +291 -0
  6. package/dist/middleware/postgraphile.js +80 -0
  7. package/dist/middleware/yoga.js +32 -0
  8. package/dist/src/tsdoc-metadata.json +11 -0
  9. package/dist/tests/ability.test.d.ts +2 -0
  10. package/dist/tests/ability.test.d.ts.map +1 -0
  11. package/dist/tests/ability.test.js +402 -0
  12. package/dist/tests/helpers/test-utils.d.ts +46 -0
  13. package/dist/tests/helpers/test-utils.d.ts.map +1 -0
  14. package/dist/tests/helpers/test-utils.js +92 -0
  15. package/dist/tests/introspection.test.d.ts +2 -0
  16. package/dist/tests/introspection.test.d.ts.map +1 -0
  17. package/dist/tests/introspection.test.js +368 -0
  18. package/dist/tests/jwt.test.d.ts +2 -0
  19. package/dist/tests/jwt.test.d.ts.map +1 -0
  20. package/dist/tests/jwt.test.js +700 -0
  21. package/dist/tests/middleware.test.d.ts +2 -0
  22. package/dist/tests/middleware.test.d.ts.map +1 -0
  23. package/dist/tests/middleware.test.js +184 -0
  24. package/dist/tests/postgraphile-plugin.test.d.ts +2 -0
  25. package/dist/tests/postgraphile-plugin.test.d.ts.map +1 -0
  26. package/dist/tests/postgraphile-plugin.test.js +56 -0
  27. package/dist/tests/setup.d.ts +2 -0
  28. package/dist/tests/setup.d.ts.map +1 -0
  29. package/dist/tests/setup.js +11 -0
  30. package/dist/tests/types.test.d.ts +2 -0
  31. package/dist/tests/types.test.d.ts.map +1 -0
  32. package/dist/tests/types.test.js +143 -0
  33. package/dist/tests/user-roles.test.d.ts +2 -0
  34. package/dist/tests/user-roles.test.d.ts.map +1 -0
  35. package/dist/tests/user-roles.test.js +157 -0
  36. package/dist/tests/yoga-plugin.test.d.ts +2 -0
  37. package/dist/tests/yoga-plugin.test.d.ts.map +1 -0
  38. package/dist/tests/yoga-plugin.test.js +125 -0
  39. package/dist/types/index.js +0 -0
  40. package/package.json +1 -1
@@ -0,0 +1,368 @@
1
+ // casl-middleware/tests/introspection.test.ts
2
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
3
+ import { AbilityBuilder, PureAbility } from '@casl/ability';
4
+ import { createIntrospectionMiddleware, createIntrospectionAbilityRules, createPostgraphileIntrospectionPlugin, createSecureGraphQLMiddleware, } from '../src/middleware/introspection';
5
+ const Ability = PureAbility;
6
+ describe('Introspection Middleware', () => {
7
+ let mockResolve;
8
+ let mockContext;
9
+ let mockInfo;
10
+ beforeEach(() => {
11
+ mockResolve = vi.fn().mockResolvedValue({
12
+ types: [
13
+ { name: 'User', fields: [{ name: 'id' }, { name: 'email' }] },
14
+ { name: 'AdminData', fields: [{ name: 'secret' }] },
15
+ { name: 'Post', fields: [{ name: 'title' }, { name: 'content' }] },
16
+ ],
17
+ });
18
+ mockContext = {
19
+ user: { id: 'user-123', roles: ['user'] },
20
+ ability: undefined,
21
+ };
22
+ mockInfo = {
23
+ fieldName: '__schema',
24
+ parentType: { name: 'Query' },
25
+ };
26
+ });
27
+ describe('Basic Introspection Control', () => {
28
+ it('should allow introspection when enabled', async () => {
29
+ const middleware = createIntrospectionMiddleware({
30
+ enabled: true,
31
+ });
32
+ const result = await middleware(mockResolve, {}, {}, mockContext, mockInfo);
33
+ expect(mockResolve).toHaveBeenCalled();
34
+ expect(result).toBeDefined();
35
+ });
36
+ it('should block introspection when disabled', async () => {
37
+ const middleware = createIntrospectionMiddleware({
38
+ enabled: false,
39
+ });
40
+ await expect(middleware(mockResolve, {}, {}, mockContext, mockInfo)).rejects.toThrow('Introspection is disabled');
41
+ expect(mockResolve).not.toHaveBeenCalled();
42
+ });
43
+ it('should pass through non-introspection queries', async () => {
44
+ const middleware = createIntrospectionMiddleware({
45
+ enabled: false, // Even when disabled
46
+ });
47
+ mockInfo.fieldName = 'getUser'; // Not an introspection query
48
+ const result = await middleware(mockResolve, {}, {}, mockContext, mockInfo);
49
+ expect(mockResolve).toHaveBeenCalled();
50
+ expect(result).toBeDefined();
51
+ });
52
+ it('should detect __type introspection queries', async () => {
53
+ const middleware = createIntrospectionMiddleware({
54
+ enabled: false,
55
+ });
56
+ mockInfo.fieldName = '__type';
57
+ await expect(middleware(mockResolve, {}, {}, mockContext, mockInfo)).rejects.toThrow('Introspection is disabled');
58
+ });
59
+ it('should detect nested introspection queries', async () => {
60
+ const middleware = createIntrospectionMiddleware({
61
+ enabled: false,
62
+ });
63
+ mockInfo.fieldName = 'fields';
64
+ mockInfo.parentType = { name: '__Type' };
65
+ await expect(middleware(mockResolve, {}, {}, mockContext, mockInfo)).rejects.toThrow('Introspection is disabled');
66
+ });
67
+ });
68
+ describe('Authentication Requirements', () => {
69
+ it('should require authentication by default', async () => {
70
+ const middleware = createIntrospectionMiddleware({
71
+ enabled: true,
72
+ allowAnonymous: false,
73
+ });
74
+ mockContext.user = undefined; // No user
75
+ await expect(middleware(mockResolve, {}, {}, mockContext, mockInfo)).rejects.toThrow('Authentication required for introspection');
76
+ });
77
+ it('should allow anonymous introspection when configured', async () => {
78
+ const middleware = createIntrospectionMiddleware({
79
+ enabled: true,
80
+ allowAnonymous: true,
81
+ });
82
+ mockContext.user = undefined; // No user
83
+ const result = await middleware(mockResolve, {}, {}, mockContext, mockInfo);
84
+ expect(mockResolve).toHaveBeenCalled();
85
+ expect(result).toBeDefined();
86
+ });
87
+ });
88
+ describe('Role-Based Access Control', () => {
89
+ it('should allow introspection for allowed roles', async () => {
90
+ const middleware = createIntrospectionMiddleware({
91
+ enabled: true,
92
+ allowedRoles: ['admin', 'developer'],
93
+ });
94
+ mockContext.user = { id: 'user-123', roles: ['developer'] };
95
+ const result = await middleware(mockResolve, {}, {}, mockContext, mockInfo);
96
+ expect(mockResolve).toHaveBeenCalled();
97
+ });
98
+ it('should deny introspection for non-allowed roles', async () => {
99
+ const middleware = createIntrospectionMiddleware({
100
+ enabled: true,
101
+ allowedRoles: ['admin', 'developer'],
102
+ });
103
+ mockContext.user = { id: 'user-123', roles: ['user'] };
104
+ await expect(middleware(mockResolve, {}, {}, mockContext, mockInfo)).rejects.toThrow('Insufficient permissions for introspection');
105
+ });
106
+ it('should handle users with multiple roles', async () => {
107
+ const middleware = createIntrospectionMiddleware({
108
+ enabled: true,
109
+ allowedRoles: ['admin', 'developer'],
110
+ });
111
+ mockContext.user = { id: 'user-123', roles: ['user', 'developer'] };
112
+ const result = await middleware(mockResolve, {}, {}, mockContext, mockInfo);
113
+ expect(mockResolve).toHaveBeenCalled();
114
+ });
115
+ it('should handle empty allowed roles array', async () => {
116
+ const middleware = createIntrospectionMiddleware({
117
+ enabled: true,
118
+ allowedRoles: [],
119
+ });
120
+ // Empty allowed roles means no role-based restriction
121
+ const result = await middleware(mockResolve, {}, {}, mockContext, mockInfo);
122
+ expect(mockResolve).toHaveBeenCalled();
123
+ });
124
+ });
125
+ describe('CASL Integration', () => {
126
+ it('should check CASL permissions when ability exists', async () => {
127
+ const { can, build } = new AbilityBuilder(Ability);
128
+ can('read', '__Schema');
129
+ const ability = build();
130
+ mockContext.ability = ability;
131
+ const middleware = createIntrospectionMiddleware({
132
+ enabled: true,
133
+ });
134
+ const result = await middleware(mockResolve, {}, {}, mockContext, mockInfo);
135
+ expect(mockResolve).toHaveBeenCalled();
136
+ });
137
+ it('should deny when CASL permission is not granted', async () => {
138
+ const { can, build } = new AbilityBuilder(Ability);
139
+ can('read', 'User'); // Can read User, but not __Schema
140
+ const ability = build();
141
+ mockContext.ability = ability;
142
+ const middleware = createIntrospectionMiddleware({
143
+ enabled: true,
144
+ });
145
+ await expect(middleware(mockResolve, {}, {}, mockContext, mockInfo)).rejects.toThrow('Permission denied for schema introspection');
146
+ });
147
+ });
148
+ describe('Custom Check Function', () => {
149
+ it('should use custom check function when provided', async () => {
150
+ const customCheck = vi.fn().mockResolvedValue(true);
151
+ const middleware = createIntrospectionMiddleware({
152
+ enabled: true,
153
+ customCheck,
154
+ });
155
+ await middleware(mockResolve, {}, {}, mockContext, mockInfo);
156
+ expect(customCheck).toHaveBeenCalledWith(mockContext);
157
+ expect(mockResolve).toHaveBeenCalled();
158
+ });
159
+ it('should deny when custom check returns false', async () => {
160
+ const customCheck = vi.fn().mockResolvedValue(false);
161
+ const middleware = createIntrospectionMiddleware({
162
+ enabled: true,
163
+ customCheck,
164
+ });
165
+ await expect(middleware(mockResolve, {}, {}, mockContext, mockInfo)).rejects.toThrow('Introspection not allowed');
166
+ expect(customCheck).toHaveBeenCalledWith(mockContext);
167
+ expect(mockResolve).not.toHaveBeenCalled();
168
+ });
169
+ it('should handle async custom check functions', async () => {
170
+ const customCheck = vi.fn().mockImplementation(async (context) => {
171
+ await new Promise(resolve => setTimeout(resolve, 10));
172
+ return context.user?.roles?.includes('admin');
173
+ });
174
+ const middleware = createIntrospectionMiddleware({
175
+ enabled: true,
176
+ customCheck,
177
+ });
178
+ mockContext.user = { id: 'user-123', roles: ['admin'] };
179
+ await middleware(mockResolve, {}, {}, mockContext, mockInfo);
180
+ expect(customCheck).toHaveBeenCalledWith(mockContext);
181
+ expect(mockResolve).toHaveBeenCalled();
182
+ });
183
+ });
184
+ describe('Type Filtering', () => {
185
+ it('should filter types based on permissions', async () => {
186
+ const { can, build } = new AbilityBuilder(Ability);
187
+ can('read', '__Schema');
188
+ can('read', 'User');
189
+ can('read', 'Post');
190
+ // Cannot read AdminData
191
+ const ability = build();
192
+ mockContext.ability = ability;
193
+ const middleware = createIntrospectionMiddleware({
194
+ enabled: true,
195
+ typePermissions: {
196
+ AdminData: { action: 'read', subject: 'AdminData' },
197
+ },
198
+ });
199
+ const result = await middleware(mockResolve, {}, {}, mockContext, mockInfo);
200
+ // Should filter out AdminData
201
+ expect(result.types).toHaveLength(2);
202
+ expect(result.types.map((t) => t.name)).toEqual(['User', 'Post']);
203
+ expect(result.types.map((t) => t.name)).not.toContain('AdminData');
204
+ });
205
+ it('should keep types without permission requirements', async () => {
206
+ const { can, build } = new AbilityBuilder(Ability);
207
+ can('read', '__Schema');
208
+ const ability = build();
209
+ mockContext.ability = ability;
210
+ const middleware = createIntrospectionMiddleware({
211
+ enabled: true,
212
+ typePermissions: {
213
+ AdminData: { action: 'read', subject: 'AdminData' },
214
+ },
215
+ });
216
+ const result = await middleware(mockResolve, {}, {}, mockContext, mockInfo);
217
+ // Should keep User and Post (no permission requirements)
218
+ // Should filter out AdminData
219
+ expect(result.types.map((t) => t.name)).toContain('User');
220
+ expect(result.types.map((t) => t.name)).toContain('Post');
221
+ expect(result.types.map((t) => t.name)).not.toContain('AdminData');
222
+ });
223
+ });
224
+ describe('Field Filtering', () => {
225
+ it('should filter fields based on permissions', async () => {
226
+ const { can, build } = new AbilityBuilder(Ability);
227
+ can('read', '__Schema');
228
+ can('read', 'UserPublicFields');
229
+ // Cannot read User.email
230
+ const ability = build();
231
+ mockContext.ability = ability;
232
+ const middleware = createIntrospectionMiddleware({
233
+ enabled: true,
234
+ fieldPermissions: {
235
+ 'User.email': { action: 'read', subject: 'UserEmail' },
236
+ },
237
+ });
238
+ const result = await middleware(mockResolve, {}, {}, mockContext, mockInfo);
239
+ const userType = result.types.find((t) => t.name === 'User');
240
+ expect(userType.fields).toHaveLength(1);
241
+ expect(userType.fields[0].name).toBe('id');
242
+ });
243
+ it('should handle __type field filtering', async () => {
244
+ const { can, build } = new AbilityBuilder(Ability);
245
+ can('read', '__Schema');
246
+ const ability = build();
247
+ mockContext.ability = ability;
248
+ mockInfo.fieldName = '__type';
249
+ // Mock __type result
250
+ mockResolve.mockResolvedValue({
251
+ name: 'User',
252
+ fields: [{ name: 'id' }, { name: 'email' }, { name: 'password' }],
253
+ });
254
+ const middleware = createIntrospectionMiddleware({
255
+ enabled: true,
256
+ fieldPermissions: {
257
+ 'User.password': { action: 'read', subject: 'UserPassword' },
258
+ },
259
+ });
260
+ const result = await middleware(mockResolve, {}, {}, mockContext, mockInfo);
261
+ // Should filter out password field
262
+ expect(result.fields).toHaveLength(2);
263
+ expect(result.fields.map((f) => f.name)).toEqual(['id', 'email']);
264
+ });
265
+ });
266
+ describe('createIntrospectionAbilityRules', () => {
267
+ it('should return empty rules for anonymous users', () => {
268
+ const rules = createIntrospectionAbilityRules();
269
+ expect(rules).toEqual([]);
270
+ });
271
+ it('should return admin rules for admin users', () => {
272
+ const rules = createIntrospectionAbilityRules({ roles: ['admin'] });
273
+ expect(rules).toEqual([
274
+ { action: 'read', subject: '__Schema' },
275
+ { action: 'read', subject: '__Type' },
276
+ ]);
277
+ });
278
+ it('should return developer rules for developer users', () => {
279
+ const rules = createIntrospectionAbilityRules({ roles: ['developer'] });
280
+ expect(rules).toEqual([
281
+ { action: 'read', subject: '__Schema' },
282
+ { action: 'read', subject: '__Type' },
283
+ ]);
284
+ });
285
+ it('should return limited rules for regular users', () => {
286
+ const rules = createIntrospectionAbilityRules({ roles: ['user'] });
287
+ expect(rules).toEqual([{ action: 'read', subject: '__Schema' }]);
288
+ });
289
+ it('should handle users with multiple roles (admin takes precedence)', () => {
290
+ const rules = createIntrospectionAbilityRules({ roles: ['user', 'admin'] });
291
+ // Admin rules should be returned (early return)
292
+ expect(rules).toEqual([
293
+ { action: 'read', subject: '__Schema' },
294
+ { action: 'read', subject: '__Type' },
295
+ ]);
296
+ });
297
+ });
298
+ describe('Postgraphile Plugin', () => {
299
+ it('should create a valid plugin object', () => {
300
+ const plugin = createPostgraphileIntrospectionPlugin({
301
+ enabled: true,
302
+ });
303
+ expect(plugin).toBeDefined();
304
+ expect(plugin.name).toBe('IntrospectionControlPlugin');
305
+ expect(plugin.version).toBe('1.0.0');
306
+ expect(plugin.grafast).toBeDefined();
307
+ });
308
+ it('should warn when introspection is disabled', () => {
309
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
310
+ const plugin = createPostgraphileIntrospectionPlugin({
311
+ enabled: false,
312
+ });
313
+ // Simulate the hook being called
314
+ const schema = {};
315
+ plugin.grafast.hooks.GraphQLSchema(schema);
316
+ expect(consoleSpy).toHaveBeenCalledWith('Introspection control in Postgraphile requires custom implementation');
317
+ consoleSpy.mockRestore();
318
+ });
319
+ });
320
+ describe('createSecureGraphQLMiddleware', () => {
321
+ it('should combine middlewares', () => {
322
+ const middleware = createSecureGraphQLMiddleware({
323
+ introspection: {
324
+ enabled: true,
325
+ allowedRoles: ['admin'],
326
+ },
327
+ });
328
+ expect(middleware).toBeInstanceOf(Function);
329
+ });
330
+ it('should apply introspection middleware when configured', async () => {
331
+ const mockResolve = vi.fn().mockResolvedValue({ data: 'test' });
332
+ const middleware = createSecureGraphQLMiddleware({
333
+ introspection: {
334
+ enabled: false,
335
+ },
336
+ });
337
+ mockInfo.fieldName = '__schema';
338
+ await expect(middleware(mockResolve, {}, {}, mockContext, mockInfo)).rejects.toThrow('Introspection is disabled');
339
+ });
340
+ it('should pass through when no introspection config', async () => {
341
+ const mockResolve = vi.fn().mockResolvedValue({ data: 'test' });
342
+ const middleware = createSecureGraphQLMiddleware({});
343
+ const result = await middleware(mockResolve, {}, {}, mockContext, mockInfo);
344
+ expect(mockResolve).toHaveBeenCalled();
345
+ expect(result).toEqual({ data: 'test' });
346
+ });
347
+ });
348
+ describe('Environment-based Defaults', () => {
349
+ it('should default to enabled in development', () => {
350
+ const originalEnv = process.env.NODE_ENV;
351
+ process.env.NODE_ENV = 'development';
352
+ const middleware = createIntrospectionMiddleware({});
353
+ // Should not throw
354
+ expect(async () => {
355
+ await middleware(mockResolve, {}, {}, mockContext, mockInfo);
356
+ }).not.toThrow();
357
+ process.env.NODE_ENV = originalEnv;
358
+ });
359
+ it('should default to disabled in production', async () => {
360
+ const originalEnv = process.env.NODE_ENV;
361
+ process.env.NODE_ENV = 'production';
362
+ const middleware = createIntrospectionMiddleware({});
363
+ // Should throw in production
364
+ await expect(middleware(mockResolve, {}, {}, mockContext, mockInfo)).rejects.toThrow('Introspection is disabled');
365
+ process.env.NODE_ENV = originalEnv;
366
+ });
367
+ });
368
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=jwt.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"jwt.test.d.ts","sourceRoot":"","sources":["../../tests/jwt.test.ts"],"names":[],"mappings":""}