Skip to content

Commit

Permalink
feat(graphql,auth): Add authorization to resolvers and relations
Browse files Browse the repository at this point in the history
  • Loading branch information
doug-martin committed Aug 31, 2020
1 parent 29fdfa7 commit 9d76787
Show file tree
Hide file tree
Showing 57 changed files with 915 additions and 198 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { CRUDAuth, Relation } from '../../src';
import { createDefaultCRUDAuthService } from '../../src/auth';

describe('createDefaultCRUDAuthService', () => {
type UserContext = { user: { id: number } };
class TestRelation {
relationOwnerId!: number;
}

@CRUDAuth({ filter: (ctx: UserContext) => ({ decoratorOwnerId: { eq: ctx.user.id } }) })
class TestDecoratorRelation {
decoratorOwnerId!: number;
}

@CRUDAuth({ filter: (ctx: UserContext) => ({ ownerId: { eq: ctx.user.id } }) })
@Relation('relations', () => TestRelation, {
auth: { filter: (ctx: UserContext) => ({ relationOwnerId: { eq: ctx.user.id } }) },
})
@Relation('decoratorRelations', () => [TestDecoratorRelation])
class TestDTO {
ownerId!: number;
}

it('should create an auth filter', async () => {
const Service = createDefaultCRUDAuthService(TestDTO);
const filter = await new Service().authFilter({ user: { id: 2 } });
expect(filter).toEqual({ ownerId: { eq: 2 } });
});

it('should return an empty filter if auth not found', async () => {
class TestNoAuthDTO {
ownerId!: number;
}
const Service = createDefaultCRUDAuthService(TestNoAuthDTO);
const filter = await new Service().authFilter({ user: { id: 2 } });
expect(filter).toEqual({});
});

it('should create an auth filter for relations using the default auth decorator', async () => {
const Service = createDefaultCRUDAuthService(TestDTO);
const filter = await new Service().relationAuthFilter('decoratorRelations', { user: { id: 2 } });
expect(filter).toEqual({ decoratorOwnerId: { eq: 2 } });
});

it('should create an auth filter for relations using the relation options', async () => {
const Service = createDefaultCRUDAuthService(TestDTO);
const filter = await new Service().relationAuthFilter('relations', { user: { id: 2 } });
expect(filter).toEqual({ relationOwnerId: { eq: 2 } });
});

it('should return an empty object for an unknown relation', async () => {
const Service = createDefaultCRUDAuthService(TestDTO);
const filter = await new Service().relationAuthFilter('unknownRelations', { user: { id: 2 } });
expect(filter).toEqual({});
});
});
4 changes: 2 additions & 2 deletions packages/query-graphql/__tests__/module.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ describe('NestjsQueryGraphQLModule', () => {
});
expect(graphqlModule.imports).toHaveLength(1);
expect(graphqlModule.module).toBe(NestjsQueryGraphQLModule);
expect(graphqlModule.providers).toHaveLength(2);
expect(graphqlModule.exports).toHaveLength(3);
expect(graphqlModule.providers).toHaveLength(3);
expect(graphqlModule.exports).toHaveLength(4);
});
});
17 changes: 14 additions & 3 deletions packages/query-graphql/__tests__/resolvers/__fixtures__/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import { resolve } from 'path';
import { instance, mock } from 'ts-mockito';
import { Test } from '@nestjs/testing';
import { PubSub } from 'graphql-subscriptions';
import { pubSubToken } from '../../../src/subscription';
import { CRUDAuthService, pubSubToken } from '../../../src';
import { readGraphql } from '../../__fixtures__';
import { TestService } from './test-resolver.service';
import { TestResolverDTO } from './test-resolver.dto';
import { TestResolverAuthService } from './test-resolver-auth.service';

export { TestResolverDTO } from './test-resolver.dto';
export { TestResolverInputDTO } from './test-resolver-input.dto';
Expand All @@ -15,18 +17,27 @@ interface ResolverMock<T> {
resolver: T;
mockService: TestService;
mockPubSub: PubSub;
mockAuthService: CRUDAuthService<TestResolverDTO>;
}

export const createResolverFromNest = async <T>(ResolverClass: Class<T>): Promise<ResolverMock<T>> => {
const mockService = mock(TestService);
const mockPubSub = mock(PubSub);
const mockAuthService = mock(TestResolverAuthService);
const moduleRef = await Test.createTestingModule({
providers: [ResolverClass, TestService, { provide: pubSubToken(), useValue: instance(mockPubSub) }],
providers: [
ResolverClass,
TestService,
TestResolverAuthService,
{ provide: pubSubToken(), useValue: instance(mockPubSub) },
],
})
.overrideProvider(TestService)
.useValue(instance(mockService))
.overrideProvider(TestResolverAuthService)
.useValue(instance(mockAuthService))
.compile();
return { resolver: moduleRef.get(ResolverClass), mockService, mockPubSub };
return { resolver: moduleRef.get(ResolverClass), mockService, mockPubSub, mockAuthService };
};

export const deleteBasicResolverSDL = readGraphql(resolve(__dirname, 'delete', 'delete-basic.resolver.graphql'));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ type TestResolverDTOConnection {
}

type Query {
testResolverDTO(id: ID!): TestResolverDTO
testResolverDTO(
"""The id of the record to find."""
id: ID!
): TestResolverDTO
testResolverDTOS(
"""Limit or page results."""
paging: CursorPaging = {first: 10}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,10 @@ type TotalCountDTOConnection {
}

type Query {
totalCountDTO(id: ID!): TotalCountDTO
totalCountDTO(
"""The id of the record to find."""
id: ID!
): TotalCountDTO
totalCountDTOS(
"""Limit or page results."""
paging: CursorPaging = {first: 10}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ type CustomConnection {
}

type Query {
testResolverDTO(id: ID!): TestResolverDTO
testResolverDTO(
"""The id of the record to find."""
id: ID!
): TestResolverDTO
testResolverDTOS(
"""Limit or page results."""
paging: CursorPaging = {first: 10}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ type TestResolverDTOConnection {
}

type Query {
testResolverDTO(id: ID!): TestResolverDTO
testResolverDTO(
"""The id of the record to find."""
id: ID!
): TestResolverDTO
testResolverDTOS(
other: String!

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ type PageInfo {
}

type Query {
testResolverDTO(id: ID!): TestResolverDTO
testResolverDTO(
"""The id of the record to find."""
id: ID!
): TestResolverDTO
test: TestResolverDTO!
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ type PageInfo {
}

type Query {
testResolverDTO(id: ID!): TestResolverDTO
testResolverDTO(
"""The id of the record to find."""
id: ID!
): TestResolverDTO
testResolverDTOS(
"""Limit or page results."""
paging: OffsetPaging = {limit: 10}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Filter } from '@nestjs-query/core';
import { CRUDAuthService, AuthorizationService } from '../../../src';
import { TestResolverDTO } from './test-resolver.dto';

@AuthorizationService(TestResolverDTO)
export class TestResolverAuthService implements CRUDAuthService<TestResolverDTO> {
authFilter(context: any): Promise<Filter<TestResolverDTO>> {
return Promise.reject(new Error('authFilter Not Implemented'));
}

relationAuthFilter<Relation>(relationName: string, context: any): Promise<Filter<Relation>> {
return Promise.reject(new Error('relationAuthFilter Not Implemented'));
}
}
92 changes: 73 additions & 19 deletions packages/query-graphql/__tests__/resolvers/delete.resolver.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { DeleteManyResponse } from '@nestjs-query/core';
import { DeleteManyResponse, Filter } from '@nestjs-query/core';
import { Field, InputType, Query, Resolver } from '@nestjs/graphql';
import { deepEqual, objectContaining, when, verify, anything, mock, instance } from 'ts-mockito';
import { PubSub } from 'graphql-subscriptions';
Expand All @@ -21,6 +21,7 @@ import {
TestService,
} from './__fixtures__';
import { TestResolverDTO } from './__fixtures__/test-resolver.dto';
import { TestResolverAuthService } from './__fixtures__/test-resolver-auth.service';

describe('DeleteResolver', () => {
const expectResolverSDL = (sdl: string, opts?: DeleteResolverOpts<TestResolverDTO>) => {
Expand All @@ -37,7 +38,11 @@ describe('DeleteResolver', () => {
const createTestResolver = (opts?: DeleteResolverOpts<TestResolverDTO>) => {
@Resolver(() => TestResolverDTO)
class TestResolver extends DeleteResolver(TestResolverDTO, opts) {
constructor(service: TestService, @InjectPubSub() readonly pubSub: PubSub) {
constructor(
service: TestService,
@InjectPubSub() readonly pubSub: PubSub,
readonly authService: TestResolverAuthService,
) {
super(service);
}
}
Expand Down Expand Up @@ -77,16 +82,35 @@ describe('DeleteResolver', () => {
});

it('should call the service deleteOne with the provided input', async () => {
const { resolver, mockService } = await createTestResolver();
const { resolver, mockService, mockAuthService } = await createTestResolver();
const input: DeleteOneInputType = {
id: 'id-1',
};
const output: TestResolverDTO = {
id: 'id-1',
stringField: 'foo',
};
when(mockService.deleteOne(input.id)).thenResolve(output);
const result = await resolver.deleteOne({ input });
const context = {};
when(mockAuthService.authFilter(context)).thenResolve({});
when(mockService.deleteOne(input.id, deepEqual({ filter: {} }))).thenResolve(output);
const result = await resolver.deleteOne({ input }, context);
return expect(result).toEqual(output);
});

it('should call the service deleteOne with the provided input and authService filter', async () => {
const { resolver, mockService, mockAuthService } = await createTestResolver();
const input: DeleteOneInputType = {
id: 'id-1',
};
const output: TestResolverDTO = {
id: 'id-1',
stringField: 'foo',
};
const context = {};
const authFilter: Filter<TestResolverDTO> = { stringField: { eq: 'foo' } };
when(mockAuthService.authFilter(context)).thenResolve(authFilter);
when(mockService.deleteOne(input.id, deepEqual({ filter: authFilter }))).thenResolve(output);
const result = await resolver.deleteOne({ input }, context);
return expect(result).toEqual(output);
});
});
Expand All @@ -108,13 +132,29 @@ describe('DeleteResolver', () => {
});

it('should call the service deleteMany with the provided input', async () => {
const { resolver, mockService } = await createTestResolver();
const { resolver, mockService, mockAuthService } = await createTestResolver();
const input: DeleteManyInputType<TestResolverDTO> = {
filter: { id: { eq: 'id-1' } },
};
const output: DeleteManyResponse = { deletedCount: 1 };
const context = {};
when(mockAuthService.authFilter(context)).thenResolve({});
when(mockService.deleteMany(objectContaining(input.filter))).thenResolve(output);
const result = await resolver.deleteMany({ input });
const result = await resolver.deleteMany({ input }, context);
return expect(result).toEqual(output);
});

it('should call the service deleteMany with the provided input and filter from authService', async () => {
const { resolver, mockService, mockAuthService } = await createTestResolver();
const input: DeleteManyInputType<TestResolverDTO> = {
filter: { id: { eq: 'id-1' } },
};
const output: DeleteManyResponse = { deletedCount: 1 };
const context = {};
const authFilter: Filter<TestResolverDTO> = { stringField: { eq: 'foo' } };
when(mockAuthService.authFilter(context)).thenResolve(authFilter);
when(mockService.deleteMany(objectContaining({ ...input.filter, ...authFilter }))).thenResolve(output);
const result = await resolver.deleteMany({ input }, context);
return expect(result).toEqual(output);
});
});
Expand Down Expand Up @@ -148,7 +188,9 @@ describe('DeleteResolver', () => {

describe('delete one events', () => {
it('should publish events for create one when enableSubscriptions is set to true for all', async () => {
const { resolver, mockService, mockPubSub } = await createTestResolver({ enableSubscriptions: true });
const { resolver, mockService, mockPubSub, mockAuthService } = await createTestResolver({
enableSubscriptions: true,
});
const input: DeleteOneInputType = {
id: 'id-1',
};
Expand All @@ -158,15 +200,19 @@ describe('DeleteResolver', () => {
};
const eventName = getDTOEventName(EventType.DELETED_ONE, TestResolverDTO);
const event = { [eventName]: output };
when(mockService.deleteOne(input.id)).thenResolve(output);
const context = {};
when(mockAuthService.authFilter(context)).thenResolve({});
when(mockService.deleteOne(input.id, deepEqual({ filter: {} }))).thenResolve(output);
when(mockPubSub.publish(eventName, deepEqual(event))).thenResolve();
const result = await resolver.deleteOne({ input });
const result = await resolver.deleteOne({ input }, context);
verify(mockPubSub.publish(eventName, deepEqual(event))).once();
return expect(result).toEqual(output);
});

it('should publish events for create one when enableSubscriptions is set to true for createOne', async () => {
const { resolver, mockService, mockPubSub } = await createTestResolver({ one: { enableSubscriptions: true } });
const { resolver, mockService, mockPubSub, mockAuthService } = await createTestResolver({
one: { enableSubscriptions: true },
});
const input: DeleteOneInputType = {
id: 'id-1',
};
Expand All @@ -176,30 +222,36 @@ describe('DeleteResolver', () => {
};
const eventName = getDTOEventName(EventType.DELETED_ONE, TestResolverDTO);
const event = { [eventName]: output };
when(mockService.deleteOne(input.id)).thenResolve(output);
const context = {};
when(mockAuthService.authFilter(context)).thenResolve({});
when(mockService.deleteOne(input.id, deepEqual({ filter: {} }))).thenResolve(output);
when(mockPubSub.publish(eventName, deepEqual(event))).thenResolve();
const result = await resolver.deleteOne({ input });
const result = await resolver.deleteOne({ input }, context);
verify(mockPubSub.publish(eventName, deepEqual(event))).once();
return expect(result).toEqual(output);
});

it('should not publish an event if enableSubscriptions is false', async () => {
const { resolver, mockService, mockPubSub } = await createTestResolver({ enableSubscriptions: false });
const { resolver, mockService, mockPubSub, mockAuthService } = await createTestResolver({
enableSubscriptions: false,
});
const input: DeleteOneInputType = {
id: 'id-1',
};
const output: TestResolverDTO = {
id: 'id-1',
stringField: 'foo',
};
when(mockService.deleteOne(input.id)).thenResolve(output);
const result = await resolver.deleteOne({ input });
const context = {};
when(mockAuthService.authFilter(context)).thenResolve({});
when(mockService.deleteOne(input.id, deepEqual({ filter: {} }))).thenResolve(output);
const result = await resolver.deleteOne({ input }, context);
verify(mockPubSub.publish(anything(), anything())).never();
return expect(result).toEqual(output);
});

it('should not publish an event if enableSubscriptions is true and one.enableSubscriptions is false', async () => {
const { resolver, mockService, mockPubSub } = await createTestResolver({
const { resolver, mockService, mockPubSub, mockAuthService } = await createTestResolver({
enableSubscriptions: true,
one: { enableSubscriptions: false },
});
Expand All @@ -210,8 +262,10 @@ describe('DeleteResolver', () => {
id: 'id-1',
stringField: 'foo',
};
when(mockService.deleteOne(input.id)).thenResolve(output);
const result = await resolver.deleteOne({ input });
const context = {};
when(mockAuthService.authFilter(context)).thenResolve({});
when(mockService.deleteOne(input.id, deepEqual({ filter: {} }))).thenResolve(output);
const result = await resolver.deleteOne({ input }, context);
verify(mockPubSub.publish(anything(), anything())).never();
return expect(result).toEqual(output);
});
Expand Down
Loading

0 comments on commit 9d76787

Please sign in to comment.