@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.
- package/README.md +149 -0
- package/dist/casl-middleware.d.ts +158 -0
- package/dist/casl-middleware.js +40571 -0
- package/dist/casl-middleware.js.map +1 -0
- package/dist/casl_middleware.tsbuildinfo +1 -0
- package/dist/src/index.d.ts +6 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +3 -0
- package/dist/src/middleware/ability.d.ts +55 -0
- package/dist/src/middleware/ability.d.ts.map +1 -0
- package/dist/src/middleware/ability.js +139 -0
- package/dist/src/middleware/graphql.d.ts +11 -0
- package/dist/src/middleware/graphql.d.ts.map +1 -0
- package/dist/src/middleware/graphql.js +120 -0
- package/dist/src/middleware/introspection.d.ts +71 -0
- package/dist/src/middleware/introspection.d.ts.map +1 -0
- package/dist/src/middleware/introspection.js +169 -0
- package/dist/src/middleware/jwt.d.ts +114 -0
- package/dist/src/middleware/jwt.d.ts.map +1 -0
- package/dist/src/middleware/jwt.js +291 -0
- package/dist/src/middleware/postgraphile.d.ts +7 -0
- package/dist/src/middleware/postgraphile.d.ts.map +1 -0
- package/dist/src/middleware/postgraphile.js +80 -0
- package/dist/src/middleware/yoga.d.ts +15 -0
- package/dist/src/middleware/yoga.d.ts.map +1 -0
- package/dist/src/middleware/yoga.js +32 -0
- package/dist/src/tsdoc-metadata.json +11 -0
- package/dist/src/types/index.d.ts +114 -0
- package/dist/src/types/index.d.ts.map +1 -0
- package/dist/src/types/index.js +0 -0
- package/dist/tests/ability.test.d.ts +2 -0
- package/dist/tests/ability.test.d.ts.map +1 -0
- package/dist/tests/ability.test.js +125 -0
- package/dist/tests/helpers/test-utils.d.ts +46 -0
- package/dist/tests/helpers/test-utils.d.ts.map +1 -0
- package/dist/tests/helpers/test-utils.js +92 -0
- package/dist/tests/introspection.test.d.ts +2 -0
- package/dist/tests/introspection.test.d.ts.map +1 -0
- package/dist/tests/introspection.test.js +368 -0
- package/dist/tests/jwt.test.d.ts +2 -0
- package/dist/tests/jwt.test.d.ts.map +1 -0
- package/dist/tests/jwt.test.js +371 -0
- package/dist/tests/middleware.test.d.ts +2 -0
- package/dist/tests/middleware.test.d.ts.map +1 -0
- package/dist/tests/middleware.test.js +184 -0
- package/dist/tests/postgraphile-plugin.test.d.ts +2 -0
- package/dist/tests/postgraphile-plugin.test.d.ts.map +1 -0
- package/dist/tests/postgraphile-plugin.test.js +56 -0
- package/dist/tests/setup.d.ts +2 -0
- package/dist/tests/setup.d.ts.map +1 -0
- package/dist/tests/setup.js +11 -0
- package/dist/tests/user-roles.test.d.ts +2 -0
- package/dist/tests/user-roles.test.d.ts.map +1 -0
- package/dist/tests/user-roles.test.js +157 -0
- package/dist/tests/yoga-plugin.test.d.ts +2 -0
- package/dist/tests/yoga-plugin.test.d.ts.map +1 -0
- package/dist/tests/yoga-plugin.test.js +47 -0
- package/package.json +91 -0
- package/src/index.ts +15 -0
- package/src/middleware/ability.ts +191 -0
- package/src/middleware/graphql.ts +157 -0
- package/src/middleware/introspection.ts +258 -0
- package/src/middleware/jwt.ts +394 -0
- package/src/middleware/postgraphile.ts +93 -0
- package/src/middleware/yoga.ts +39 -0
- package/src/types/index.ts +133 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ability.test.d.ts","sourceRoot":"","sources":["../../tests/ability.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { AbilityBuilder, PureAbility, subject } from '@casl/ability';
|
|
2
|
+
import { describe, it, expect } from 'vitest';
|
|
3
|
+
import { createAbility, detectSubjectType } from '../src/middleware/ability';
|
|
4
|
+
const Ability = PureAbility;
|
|
5
|
+
describe('Ability Creation', () => {
|
|
6
|
+
describe('createAbility', () => {
|
|
7
|
+
// Create a test builder with proper matchers - using the same detectSubjectType as production
|
|
8
|
+
const testBuilder = (user) => {
|
|
9
|
+
const { can, cannot, build } = new AbilityBuilder(Ability);
|
|
10
|
+
if (!user) {
|
|
11
|
+
can('read', 'Query');
|
|
12
|
+
can('read', 'PublicType');
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
can('read', 'Query');
|
|
16
|
+
can('read', 'Mutation');
|
|
17
|
+
if (user.roles?.includes('admin')) {
|
|
18
|
+
can('manage', 'all');
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
can('read', 'User', { id: user.id });
|
|
22
|
+
can('update', 'User', { id: user.id });
|
|
23
|
+
cannot('update', 'User', 'role');
|
|
24
|
+
cannot('update', 'User', 'permissions');
|
|
25
|
+
}
|
|
26
|
+
if (user.roles?.includes('moderator')) {
|
|
27
|
+
can('update', 'Post');
|
|
28
|
+
can('delete', 'Comment');
|
|
29
|
+
}
|
|
30
|
+
if (user.roles?.includes('editor')) {
|
|
31
|
+
can('create', 'Post');
|
|
32
|
+
can('update', 'Post');
|
|
33
|
+
can('delete', 'Post');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return build({
|
|
37
|
+
detectSubjectType,
|
|
38
|
+
fieldMatcher: fields => field => {
|
|
39
|
+
if (!fields || !field)
|
|
40
|
+
return false;
|
|
41
|
+
return Array.isArray(fields) ? fields.includes(field) : fields === field;
|
|
42
|
+
},
|
|
43
|
+
conditionsMatcher: conditions => object => {
|
|
44
|
+
if (!conditions || !object)
|
|
45
|
+
return true;
|
|
46
|
+
return Object.keys(conditions).every(key => object[key] === conditions[key]);
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
it('should create a basic ability for unauthenticated users', async () => {
|
|
51
|
+
const ability = await createAbility(undefined, testBuilder);
|
|
52
|
+
expect(ability).toBeDefined();
|
|
53
|
+
expect(ability.can('read', 'Query')).toBe(true);
|
|
54
|
+
expect(ability.can('read', 'PublicType')).toBe(true);
|
|
55
|
+
expect(ability.can('read', 'Mutation')).toBe(false);
|
|
56
|
+
expect(ability.can('manage', 'all')).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
it('should create abilities for authenticated users without roles', async () => {
|
|
59
|
+
const user = { id: '123', roles: [] };
|
|
60
|
+
const ability = await createAbility(user, testBuilder);
|
|
61
|
+
expect(ability.can('read', 'Query')).toBe(true);
|
|
62
|
+
expect(ability.can('read', 'Mutation')).toBe(true);
|
|
63
|
+
expect(ability.can('read', subject('User', { id: '123' }))).toBe(true);
|
|
64
|
+
expect(ability.can('update', subject('User', { id: '123' }))).toBe(true);
|
|
65
|
+
expect(ability.can('update', subject('User', { id: '456' }))).toBe(false);
|
|
66
|
+
// Check field-level restrictions
|
|
67
|
+
expect(ability.can('update', 'User', 'role')).toBe(false);
|
|
68
|
+
expect(ability.can('update', 'User', 'permissions')).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
it('should create admin abilities for users with admin role', async () => {
|
|
71
|
+
const user = { id: '123', roles: ['admin'] };
|
|
72
|
+
const ability = await createAbility(user, testBuilder);
|
|
73
|
+
expect(ability.can('manage', 'all')).toBe(true);
|
|
74
|
+
expect(ability.can('read', 'Query')).toBe(true);
|
|
75
|
+
expect(ability.can('update', subject('User', { id: '456' }))).toBe(true);
|
|
76
|
+
expect(ability.can('delete', 'User')).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
it('should handle mixed roles correctly', async () => {
|
|
79
|
+
const user = { id: '123', roles: ['user', 'moderator'] };
|
|
80
|
+
const ability = await createAbility(user, testBuilder);
|
|
81
|
+
expect(ability.can('read', 'Query')).toBe(true);
|
|
82
|
+
expect(ability.can('read', 'Mutation')).toBe(true);
|
|
83
|
+
expect(ability.can('manage', 'all')).toBe(false);
|
|
84
|
+
expect(ability.can('read', subject('User', { id: '123' }))).toBe(true);
|
|
85
|
+
expect(ability.can('update', subject('User', { id: '123' }))).toBe(true);
|
|
86
|
+
// Check field restrictions
|
|
87
|
+
expect(ability.can('update', 'User', 'role')).toBe(false);
|
|
88
|
+
expect(ability.can('update', 'User', 'permissions')).toBe(false);
|
|
89
|
+
// Check moderator permissions
|
|
90
|
+
expect(ability.can('update', 'Post')).toBe(true);
|
|
91
|
+
expect(ability.can('delete', 'Comment')).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
describe('Custom Ability Builder', () => {
|
|
95
|
+
it('should allow custom ability definitions', () => {
|
|
96
|
+
const customCreateAbility = (user) => {
|
|
97
|
+
const { can, build } = new AbilityBuilder(Ability);
|
|
98
|
+
if (user?.permissions?.includes('read:posts')) {
|
|
99
|
+
can('read', 'Post');
|
|
100
|
+
}
|
|
101
|
+
if (user?.permissions?.includes('write:posts')) {
|
|
102
|
+
can(['create', 'update'], 'Post', { authorId: user.id });
|
|
103
|
+
}
|
|
104
|
+
if (user?.permissions?.includes('delete:posts')) {
|
|
105
|
+
can('delete', 'Post', { authorId: user.id });
|
|
106
|
+
}
|
|
107
|
+
return build({
|
|
108
|
+
detectSubjectType,
|
|
109
|
+
conditionsMatcher: conditions => object => {
|
|
110
|
+
if (!conditions || !object)
|
|
111
|
+
return true;
|
|
112
|
+
return Object.keys(conditions).every(key => object[key] === conditions[key]);
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
};
|
|
116
|
+
const user = { id: '123', permissions: ['read:posts', 'write:posts'] };
|
|
117
|
+
const ability = customCreateAbility(user);
|
|
118
|
+
expect(ability.can('read', 'Post')).toBe(true);
|
|
119
|
+
expect(ability.can('create', subject('Post', { authorId: '123' }))).toBe(true);
|
|
120
|
+
expect(ability.can('update', subject('Post', { authorId: '123' }))).toBe(true);
|
|
121
|
+
expect(ability.can('delete', subject('Post', { authorId: '123' }))).toBe(false);
|
|
122
|
+
expect(ability.can('update', subject('Post', { authorId: '456' }))).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { PureAbility } from '@casl/ability';
|
|
2
|
+
import { GraphQLSchema } from 'graphql';
|
|
3
|
+
import type { AppAbility } from '../../src/middleware/ability';
|
|
4
|
+
import { Context, User } from '../../src/types';
|
|
5
|
+
/**
|
|
6
|
+
* Create a mock GraphQL context for testing
|
|
7
|
+
*/
|
|
8
|
+
export declare const createMockContext: (user?: User, ability?: AppAbility) => Context;
|
|
9
|
+
/**
|
|
10
|
+
* Create a simple test schema
|
|
11
|
+
*/
|
|
12
|
+
export declare const createTestSchema: () => GraphQLSchema;
|
|
13
|
+
/**
|
|
14
|
+
* Create test users with different roles
|
|
15
|
+
*/
|
|
16
|
+
export declare const testUsers: {
|
|
17
|
+
public: undefined;
|
|
18
|
+
regular: {
|
|
19
|
+
id: string;
|
|
20
|
+
roles: string[];
|
|
21
|
+
};
|
|
22
|
+
moderator: {
|
|
23
|
+
id: string;
|
|
24
|
+
roles: string[];
|
|
25
|
+
};
|
|
26
|
+
editor: {
|
|
27
|
+
id: string;
|
|
28
|
+
roles: string[];
|
|
29
|
+
};
|
|
30
|
+
admin: {
|
|
31
|
+
id: string;
|
|
32
|
+
roles: string[];
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Assertion helper for checking abilities
|
|
37
|
+
*/
|
|
38
|
+
export declare const expectAbility: (ability: PureAbility) => {
|
|
39
|
+
toAllow: (action: string, subject: string, conditions?: any) => void;
|
|
40
|
+
toDeny: (action: string, subject: string, conditions?: any) => void;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Mock GraphQL ResolveInfo for testing
|
|
44
|
+
*/
|
|
45
|
+
export declare const createMockResolveInfo: (overrides?: any) => any;
|
|
46
|
+
//# sourceMappingURL=test-utils.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"test-utils.d.ts","sourceRoot":"","sources":["../../../tests/helpers/test-utils.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAA;AAE3C,OAAO,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AAEvC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,8BAA8B,CAAA;AAC9D,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,iBAAiB,CAAA;AAE/C;;GAEG;AACH,eAAO,MAAM,iBAAiB,GAAI,OAAO,IAAI,EAAE,UAAU,UAAU,KAAG,OAOrE,CAAA;AAED;;GAEG;AACH,eAAO,MAAM,gBAAgB,QAAO,aA4CnC,CAAA;AAED;;GAEG;AACH,eAAO,MAAM,SAAS;;;;;;;;;;;;;;;;;;CAMrB,CAAA;AAED;;GAEG;AACH,eAAO,MAAM,aAAa,GAAI,SAAS,WAAW;sBAC/B,MAAM,WAAW,MAAM,eAAe,GAAG;qBAG1C,MAAM,WAAW,MAAM,eAAe,GAAG;CAGzD,CAAA;AAEF;;GAEG;AACH,eAAO,MAAM,qBAAqB,GAAI,YAAW,GAAQ,KAAG,GAS1D,CAAA"}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { PureAbility } from '@casl/ability';
|
|
2
|
+
import { makeExecutableSchema } from '@graphql-tools/schema';
|
|
3
|
+
/**
|
|
4
|
+
* Create a mock GraphQL context for testing
|
|
5
|
+
*/
|
|
6
|
+
export const createMockContext = (user, ability) => {
|
|
7
|
+
const defaultAbility = new PureAbility([{ action: 'read', subject: 'Query' }]);
|
|
8
|
+
return {
|
|
9
|
+
user: user || undefined,
|
|
10
|
+
ability: ability || defaultAbility,
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Create a simple test schema
|
|
15
|
+
*/
|
|
16
|
+
export const createTestSchema = () => {
|
|
17
|
+
const typeDefs = `
|
|
18
|
+
type User {
|
|
19
|
+
id: ID!
|
|
20
|
+
name: String!
|
|
21
|
+
email: String
|
|
22
|
+
role: String
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type Post {
|
|
26
|
+
id: ID!
|
|
27
|
+
title: String!
|
|
28
|
+
content: String!
|
|
29
|
+
authorId: String!
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type Query {
|
|
33
|
+
me: User
|
|
34
|
+
user(id: ID!): User
|
|
35
|
+
posts: [Post!]!
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type Mutation {
|
|
39
|
+
updateUser(id: ID!, name: String): User
|
|
40
|
+
createPost(title: String!, content: String!): Post
|
|
41
|
+
}
|
|
42
|
+
`;
|
|
43
|
+
const resolvers = {
|
|
44
|
+
Query: {
|
|
45
|
+
me: () => ({ id: '123', name: 'Test User', email: 'test@example.com', role: 'user' }),
|
|
46
|
+
user: (_, { id }) => ({ id, name: `User ${id}`, email: `user${id}@example.com`, role: 'user' }),
|
|
47
|
+
posts: () => [
|
|
48
|
+
{ id: '1', title: 'Post 1', content: 'Content 1', authorId: '123' },
|
|
49
|
+
{ id: '2', title: 'Post 2', content: 'Content 2', authorId: '456' },
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
Mutation: {
|
|
53
|
+
updateUser: (_, { id, name }) => ({ id, name: name || 'Updated', email: 'updated@example.com' }),
|
|
54
|
+
createPost: (_, { title, content }) => ({ id: '3', title, content, authorId: '123' }),
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
return makeExecutableSchema({ typeDefs, resolvers });
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Create test users with different roles
|
|
61
|
+
*/
|
|
62
|
+
export const testUsers = {
|
|
63
|
+
public: undefined,
|
|
64
|
+
regular: { id: '123', roles: ['user'] },
|
|
65
|
+
moderator: { id: '456', roles: ['user', 'moderator'] },
|
|
66
|
+
editor: { id: '789', roles: ['user', 'editor'] },
|
|
67
|
+
admin: { id: '999', roles: ['admin'] },
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Assertion helper for checking abilities
|
|
71
|
+
*/
|
|
72
|
+
export const expectAbility = (ability) => ({
|
|
73
|
+
toAllow: (action, subject, conditions) => {
|
|
74
|
+
expect(ability.can(action, subject, conditions)).toBe(true);
|
|
75
|
+
},
|
|
76
|
+
toDeny: (action, subject, conditions) => {
|
|
77
|
+
expect(ability.can(action, subject, conditions)).toBe(false);
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
/**
|
|
81
|
+
* Mock GraphQL ResolveInfo for testing
|
|
82
|
+
*/
|
|
83
|
+
export const createMockResolveInfo = (overrides = {}) => ({
|
|
84
|
+
fieldName: 'testField',
|
|
85
|
+
parentType: { name: 'Query' },
|
|
86
|
+
path: { typename: 'Query', key: 'testField' },
|
|
87
|
+
operation: {
|
|
88
|
+
operation: 'query',
|
|
89
|
+
loc: { source: { body: 'query { testField }' } },
|
|
90
|
+
},
|
|
91
|
+
...overrides,
|
|
92
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"introspection.test.d.ts","sourceRoot":"","sources":["../../tests/introspection.test.ts"],"names":[],"mappings":""}
|
|
@@ -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 @@
|
|
|
1
|
+
{"version":3,"file":"jwt.test.d.ts","sourceRoot":"","sources":["../../tests/jwt.test.ts"],"names":[],"mappings":""}
|