diff --git a/backend/src/middleware/branding/brandingMiddlewares.ts b/backend/src/middleware/branding/brandingMiddlewares.ts index 8d47043e8..f2da97682 100644 --- a/backend/src/middleware/branding/brandingMiddlewares.ts +++ b/backend/src/middleware/branding/brandingMiddlewares.ts @@ -1,6 +1,5 @@ -// eslint-disable-next-line import/no-cycle -import { MiddlewareOrder } from '@middleware/index' +// import { addMiddleware } from '@middleware/index' -export default (): MiddlewareOrder[] => { - return [] +export default () => { + // addMiddleware({ name: 'myMW', middleware: myMW, position: { } }) } diff --git a/backend/src/middleware/categories.ts b/backend/src/middleware/categories.ts index 7d9f2a71e..609811e29 100644 --- a/backend/src/middleware/categories.ts +++ b/backend/src/middleware/categories.ts @@ -1,18 +1,20 @@ +import { GraphQLResolveInfo } from 'graphql' + import type { Context } from '@src/context' type Resolver = ( root: unknown, args: unknown, context: Context, - resolveInfo: unknown, + resolveInfo: GraphQLResolveInfo, ) => Promise -const checkCategoriesActive = ( +const checkCategoriesActive = async ( resolve: Resolver, root: unknown, args: unknown, context: Context, - resolveInfo: unknown, -) => { + resolveInfo: GraphQLResolveInfo, +): Promise => { if (context.config.CATEGORIES_ACTIVE) { return resolve(root, args, context, resolveInfo) } diff --git a/backend/src/middleware/index.spec.ts b/backend/src/middleware/index.spec.ts new file mode 100644 index 000000000..4bdea7df5 --- /dev/null +++ b/backend/src/middleware/index.spec.ts @@ -0,0 +1,223 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +/* eslint-disable n/global-require */ + +// Unit tests for addMiddleware – testing append, prepend, before, after, and error cases. +// Each test uses jest.isolateModules + jest.doMock to get a fresh ocelotMiddlewares array. + +interface MiddlewareModule { + addMiddleware: (mw: { name: string; middleware: unknown; position: unknown }) => void + default: (schema: unknown) => unknown +} + +interface MockOptions { + extraMocks?: Record + disabledMiddlewares?: string[] +} + +const middlewareModules = [ + './categories', + './chatMiddleware', + './excerptMiddleware', + './hashtags/hashtagsMiddleware', + './includedFieldsMiddleware', + './languages/languages', + './login/loginMiddleware', + './notifications/notificationsMiddleware', + './orderByMiddleware', + './permissionsMiddleware', + './sentryMiddleware', + './sluggifyMiddleware', + './softDelete/softDeleteMiddleware', + './userInteractions', + './validation/validationMiddleware', + './xssMiddleware', +] + +const setupMocks = ({ extraMocks, disabledMiddlewares = [] }: MockOptions = {}) => { + jest.doMock('./branding/brandingMiddlewares', () => jest.fn()) + jest.doMock('@config/index', () => ({ DISABLED_MIDDLEWARES: disabledMiddlewares })) + + // Mock all middlewares and allow to override its mock + for (const mod of middlewareModules) { + // eslint-disable-next-line security/detect-object-injection + jest.doMock(mod, () => extraMocks?.[mod] ?? {}) + } +} + +const loadModule = ( + options?: MockOptions, +): { mod: MiddlewareModule; getCapturedMiddlewares: () => unknown[] } => { + let capturedArgs: unknown[] = [] + jest.doMock('graphql-middleware', () => ({ + applyMiddleware: (_schema: unknown, ...middlewares: unknown[]) => { + capturedArgs = middlewares + return _schema + }, + })) + setupMocks(options) + // eslint-disable-next-line n/no-missing-require + const mod = require('./index') as MiddlewareModule + return { + mod, + getCapturedMiddlewares: () => { + mod.default({}) + return capturedArgs + }, + } +} + +describe('default', () => { + it('registers the 16 default middlewares', () => { + jest.isolateModules(() => { + const { getCapturedMiddlewares } = loadModule() + expect(getCapturedMiddlewares()).toHaveLength(16) + }) + }) + + it('calls brandingMiddlewares', () => { + jest.isolateModules(() => { + const { mod } = loadModule() + // eslint-disable-next-line n/no-missing-require + const brandingMiddlewares = require('./branding/brandingMiddlewares') as jest.Mock + mod.default({}) + expect(brandingMiddlewares).toHaveBeenCalledTimes(1) + }) + }) + + it('filters out disabled middlewares', () => { + jest.isolateModules(() => { + const sentryMarker = { __test: 'sentry' } + const xssMarker = { __test: 'xss' } + const { getCapturedMiddlewares } = loadModule({ + extraMocks: { + './sentryMiddleware': sentryMarker, + './xssMiddleware': xssMarker, + }, + disabledMiddlewares: ['sentry', 'xss'], + }) + const consoleSpy = jest.spyOn(console, 'log').mockImplementation() + const middlewares = getCapturedMiddlewares() + expect(middlewares).toHaveLength(14) + expect(middlewares).not.toContain(sentryMarker) + expect(middlewares).not.toContain(xssMarker) + expect(consoleSpy).toHaveBeenCalledWith('Warning: Disabled "sentry, xss" middleware.') + consoleSpy.mockRestore() + }) + }) +}) + +describe('addMiddleware', () => { + describe('append', () => { + it('adds middleware at the end', () => { + jest.isolateModules(() => { + const { mod, getCapturedMiddlewares } = loadModule() + const m = { __test: 'appended' } + mod.addMiddleware({ name: 'test-append', middleware: m, position: 'append' }) + const middlewares = getCapturedMiddlewares() + expect(middlewares).toHaveLength(17) + expect(middlewares[16]).toBe(m) + }) + }) + }) + + describe('prepend', () => { + it('adds middleware at the beginning', () => { + jest.isolateModules(() => { + const { mod, getCapturedMiddlewares } = loadModule() + const m = { __test: 'prepended' } + mod.addMiddleware({ name: 'test-prepend', middleware: m, position: 'prepend' }) + const middlewares = getCapturedMiddlewares() + expect(middlewares).toHaveLength(17) + expect(middlewares[0]).toBe(m) + }) + }) + }) + + describe('before', () => { + it('inserts middleware directly before the named anchor', () => { + jest.isolateModules(() => { + const sentryMarker = { __test: 'sentry' } + const permissionsMarker = { __test: 'permissions' } + const { mod, getCapturedMiddlewares } = loadModule({ + extraMocks: { + './sentryMiddleware': sentryMarker, + './permissionsMiddleware': permissionsMarker, + }, + }) + + const m = { __test: 'before-permissions' } + mod.addMiddleware({ + name: 'test-before-permissions', + middleware: m, + position: { before: 'permissions' }, + }) + + const middlewares = getCapturedMiddlewares() + const idxSentry = middlewares.indexOf(sentryMarker) + const idxNew = middlewares.indexOf(m) + const idxPermissions = middlewares.indexOf(permissionsMarker) + + expect(idxSentry).toBeLessThan(idxNew) + expect(idxNew).toBe(idxPermissions - 1) + }) + }) + }) + + describe('after', () => { + it('inserts middleware directly after the named anchor', () => { + jest.isolateModules(() => { + const sentryMarker = { __test: 'sentry' } + const permissionsMarker = { __test: 'permissions' } + const { mod, getCapturedMiddlewares } = loadModule({ + extraMocks: { + './sentryMiddleware': sentryMarker, + './permissionsMiddleware': permissionsMarker, + }, + }) + + const m = { __test: 'after-sentry' } + mod.addMiddleware({ + name: 'test-after-sentry', + middleware: m, + position: { after: 'sentry' }, + }) + + const middlewares = getCapturedMiddlewares() + const idxSentry = middlewares.indexOf(sentryMarker) + const idxNew = middlewares.indexOf(m) + const idxPermissions = middlewares.indexOf(permissionsMarker) + + expect(idxNew).toBe(idxSentry + 1) + expect(idxNew).toBeLessThan(idxPermissions) + }) + }) + }) + + describe('unknown anchor', () => { + it('throws when "before" anchor does not exist', () => { + jest.isolateModules(() => { + const { mod } = loadModule() + expect(() => + mod.addMiddleware({ + name: 'failure', + middleware: {}, + position: { before: 'nonexistent' }, + }), + ).toThrow('Could not find middleware "nonexistent" to append the middleware "failure"') + }) + }) + + it('throws when "after" anchor does not exist', () => { + jest.isolateModules(() => { + const { mod } = loadModule() + expect(() => + mod.addMiddleware({ + name: 'failure', + middleware: {}, + position: { after: 'nonexistent' }, + }), + ).toThrow('Could not find middleware "nonexistent" to append the middleware "failure"') + }) + }) + }) +}) diff --git a/backend/src/middleware/index.ts b/backend/src/middleware/index.ts index 6b5ca7654..34e10eb1f 100644 --- a/backend/src/middleware/index.ts +++ b/backend/src/middleware/index.ts @@ -1,11 +1,10 @@ /* eslint-disable @typescript-eslint/no-unsafe-argument */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import { applyMiddleware, IMiddleware } from 'graphql-middleware' +import { applyMiddleware, IMiddleware, IMiddlewareGenerator } from 'graphql-middleware' import CONFIG from '@config/index' -// eslint-disable-next-line import/no-cycle import brandingMiddlewares from './branding/brandingMiddlewares' import categories from './categories' import chatMiddleware from './chatMiddleware' @@ -25,41 +24,79 @@ import validation from './validation/validationMiddleware' import xss from './xssMiddleware' export interface MiddlewareOrder { - order: number + position: 'prepend' | 'append' | { before: string } | { after: string } name: string - middleware: IMiddleware + // eslint-disable-next-line @typescript-eslint/no-explicit-any + middleware: IMiddleware | IMiddlewareGenerator } -const ocelotMiddlewares: MiddlewareOrder[] = [ - { order: -200, name: 'sentry', middleware: sentry }, - { order: -190, name: 'permissions', middleware: permissions }, - { order: -180, name: 'xss', middleware: xss }, - { order: -170, name: 'validation', middleware: validation }, - { order: -160, name: 'userInteractions', middleware: userInteractions }, - { order: -150, name: 'sluggify', middleware: sluggify }, - { order: -140, name: 'languages', middleware: languages }, - { order: -130, name: 'excerpt', middleware: excerpt }, - { order: -120, name: 'login', middleware: login }, - { order: -110, name: 'notifications', middleware: notifications }, - { order: -100, name: 'hashtags', middleware: hashtags }, - { order: -90, name: 'softDelete', middleware: softDelete }, - { order: -80, name: 'includedFields', middleware: includedFields }, - { order: -70, name: 'orderBy', middleware: orderBy }, - { order: -60, name: 'chatMiddleware', middleware: chatMiddleware }, - { order: -50, name: 'categories', middleware: categories }, -] +const ocelotMiddlewares: MiddlewareOrder[] = [] + +export const addMiddleware = (middleware: MiddlewareOrder) => { + switch (middleware.position) { + case 'append': + ocelotMiddlewares.push(middleware) + break + case 'prepend': + ocelotMiddlewares.unshift(middleware) + break + default: { + const anchor = + 'before' in middleware.position ? middleware.position.before : middleware.position.after + const appendMiddlewareAt = ocelotMiddlewares.findIndex((m) => m.name === anchor) + if (appendMiddlewareAt === -1) { + throw new Error( + `Could not find middleware "${anchor}" to append the middleware "${middleware.name}"`, + ) + } + ocelotMiddlewares.splice( + appendMiddlewareAt + ('before' in middleware.position ? 0 : 1), + 0, + middleware, + ) + } + } +} + +addMiddleware({ name: 'sentry', middleware: sentry, position: 'append' }) +addMiddleware({ name: 'permissions', middleware: permissions, position: { after: 'sentry' } }) +addMiddleware({ name: 'xss', middleware: xss, position: { after: 'permissions' } }) +addMiddleware({ name: 'validation', middleware: validation, position: { after: 'xss' } }) +addMiddleware({ + name: 'userInteractions', + middleware: userInteractions, + position: { after: 'validation' }, +}) +addMiddleware({ name: 'sluggify', middleware: sluggify, position: { after: 'userInteractions' } }) +addMiddleware({ name: 'languages', middleware: languages, position: { after: 'sluggify' } }) +addMiddleware({ name: 'excerpt', middleware: excerpt, position: { after: 'languages' } }) +addMiddleware({ name: 'login', middleware: login, position: { after: 'excerpt' } }) +addMiddleware({ name: 'notifications', middleware: notifications, position: { after: 'login' } }) +addMiddleware({ name: 'hashtags', middleware: hashtags, position: { after: 'notifications' } }) +addMiddleware({ name: 'softDelete', middleware: softDelete, position: { after: 'hashtags' } }) +addMiddleware({ + name: 'includedFields', + middleware: includedFields, + position: { after: 'softDelete' }, +}) +addMiddleware({ name: 'orderBy', middleware: orderBy, position: { after: 'includedFields' } }) +addMiddleware({ + name: 'chatMiddleware', + middleware: chatMiddleware, + position: { after: 'orderBy' }, +}) +addMiddleware({ name: 'categories', middleware: categories, position: { after: 'chatMiddleware' } }) export default (schema) => { - const middlewares = ocelotMiddlewares - .concat(brandingMiddlewares()) - .sort((a, b) => a.order - b.order) + // execute branding middleware function + brandingMiddlewares() - const filteredMiddlewares = middlewares.filter( + const filteredMiddlewares = ocelotMiddlewares.filter( (middleware) => !CONFIG.DISABLED_MIDDLEWARES.includes(middleware.name), ) // Warn if we filtered - if (middlewares.length < filteredMiddlewares.length) { + if (ocelotMiddlewares.length !== filteredMiddlewares.length) { // eslint-disable-next-line no-console console.log(`Warning: Disabled "${CONFIG.DISABLED_MIDDLEWARES.join(', ')}" middleware.`) }