diff --git a/packages/query-graphql/__tests__/interceptors/authorizer.interceptor.spec.ts b/packages/query-graphql/__tests__/interceptors/authorizer.interceptor.spec.ts new file mode 100644 index 000000000..5971ad94c --- /dev/null +++ b/packages/query-graphql/__tests__/interceptors/authorizer.interceptor.spec.ts @@ -0,0 +1,107 @@ +import { ExecutionContext } from '@nestjs/common' +import { Filter } from '@ptc-org/nestjs-query-core' + +import { Authorizer } from '../../src/auth' +import { + AuthorizerContext, + AuthorizerInterceptor, + filterReferencesField +} from '../../src/interceptors/authorizer.interceptor' + +describe('filterReferencesField', () => { + type Row = { id: number; deletedAt: Date | null; name: string } + + it('returns true when the field is a direct key', () => { + const filter: Filter = { deletedAt: { isNot: null } } + expect(filterReferencesField(filter, 'deletedAt')).toBe(true) + }) + + it('returns false when the field is absent', () => { + const filter: Filter = { name: { eq: 'x' } } + expect(filterReferencesField(filter, 'deletedAt')).toBe(false) + }) + + it('descends into and groups', () => { + const filter: Filter = { + and: [{ name: { eq: 'x' } }, { deletedAt: { isNot: null } }] + } + expect(filterReferencesField(filter, 'deletedAt')).toBe(true) + }) + + it('descends into or groups', () => { + const filter: Filter = { + or: [{ name: { eq: 'x' } }, { and: [{ deletedAt: { isNot: null } }] }] + } + expect(filterReferencesField(filter, 'deletedAt')).toBe(true) + }) + + it('returns false for undefined filter', () => { + expect(filterReferencesField(undefined, 'deletedAt')).toBe(false) + }) +}) + +describe('AuthorizerInterceptor', () => { + type Row = { id: number; deletedAt: Date | null } + + class TestDTO { + id!: number + + deletedAt!: Date | null + } + + const fakeAuthorizer = {} as Authorizer + + const createExecutionContext = (args: Record, ctx: Record = {}): ExecutionContext => { + const gqlArgs = [null, args, ctx, null] as const + return { + getType: () => 'graphql', + getArgs: () => gqlArgs, + getArgByIndex: (i: number) => gqlArgs[i], + switchToHttp: () => ({}), + switchToRpc: () => ({}), + switchToWs: () => ({}), + getClass: () => undefined, + getHandler: () => undefined + } as unknown as ExecutionContext + } + + it('populates authorizer, clientFilter, and clientFilterReferences on the gql context', async () => { + const InterceptorClass = AuthorizerInterceptor(TestDTO) + const interceptor = new InterceptorClass(fakeAuthorizer) + const gqlContext: AuthorizerContext = {} as AuthorizerContext + const filter: Filter = { deletedAt: { isNot: null } } + const execContext = createExecutionContext({ filter }, gqlContext) + + await interceptor.intercept(execContext, { handle: () => ({ subscribe: () => undefined }) as never }) + + expect(gqlContext.authorizer).toBe(fakeAuthorizer) + expect(gqlContext.clientFilter).toEqual(filter) + expect(gqlContext.clientFilterReferences?.('deletedAt')).toBe(true) + expect(gqlContext.clientFilterReferences?.('id')).toBe(false) + }) + + it('extracts filter from args.input.filter when top-level filter is missing', async () => { + const InterceptorClass = AuthorizerInterceptor(TestDTO) + const interceptor = new InterceptorClass(fakeAuthorizer) + const gqlContext: AuthorizerContext = {} as AuthorizerContext + const filter: Filter = { id: { eq: 1 } } + const execContext = createExecutionContext({ input: { filter } }, gqlContext) + + await interceptor.intercept(execContext, { handle: () => ({ subscribe: () => undefined }) as never }) + + expect(gqlContext.clientFilter).toEqual(filter) + expect(gqlContext.clientFilterReferences?.('id')).toBe(true) + }) + + it('leaves clientFilter undefined when no filter is provided', async () => { + const InterceptorClass = AuthorizerInterceptor(TestDTO) + const interceptor = new InterceptorClass(fakeAuthorizer) + const gqlContext: AuthorizerContext = {} as AuthorizerContext + const execContext = createExecutionContext({}, gqlContext) + + await interceptor.intercept(execContext, { handle: () => ({ subscribe: () => undefined }) as never }) + + expect(gqlContext.clientFilter).toBeUndefined() + expect(gqlContext.clientFilterReferences?.('deletedAt')).toBe(false) + }) +}) diff --git a/packages/query-graphql/src/interceptors/authorizer.interceptor.ts b/packages/query-graphql/src/interceptors/authorizer.interceptor.ts index 429cdfd5b..8c11378cb 100644 --- a/packages/query-graphql/src/interceptors/authorizer.interceptor.ts +++ b/packages/query-graphql/src/interceptors/authorizer.interceptor.ts @@ -1,11 +1,37 @@ import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common' import { GqlExecutionContext } from '@nestjs/graphql' -import { Class } from '@ptc-org/nestjs-query-core' +import { Class, Filter } from '@ptc-org/nestjs-query-core' import { Authorizer } from '../auth' import { InjectAuthorizer } from '../decorators' -export type AuthorizerContext = { authorizer: Authorizer } +export type ClientFilterReferences = (field: keyof DTO & string) => boolean + +export type AuthorizerContext = { + authorizer: Authorizer + clientFilter?: Filter + clientFilterReferences?: ClientFilterReferences +} + +export function filterReferencesField(filter: Filter | undefined, field: keyof DTO & string): boolean { + if (!filter) return false + for (const key of Object.keys(filter) as (keyof Filter)[]) { + if (key === field) return true + if (key === 'and' || key === 'or') { + const group = filter[key] as Filter[] | undefined + if (group?.some((sub) => filterReferencesField(sub, field))) return true + } + } + return false +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function extractClientFilter(args: Record | undefined): Filter | undefined { + if (!args) return undefined + if (args.filter) return args.filter as Filter + if (args.input?.filter) return args.input.filter as Filter + return undefined +} export function AuthorizerInterceptor(DTOClass: Class): Class { @Injectable() @@ -16,6 +42,11 @@ export function AuthorizerInterceptor(DTOClass: Class): Class>() ctx.authorizer = this.authorizer + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const args = gqlContext.getArgs>() + const clientFilter = extractClientFilter(args) + ctx.clientFilter = clientFilter + ctx.clientFilterReferences = (field) => filterReferencesField(clientFilter, field) return next.handle() } }