From b88b3d065b49a8115b2a8893199825d62a60098f Mon Sep 17 00:00:00 2001 From: Abhishek Chatterjee Date: Mon, 18 May 2026 00:49:59 +0530 Subject: [PATCH] feat(ssp): ROSS-171: move SSP logic into a reusable Fastify plugin and remove manual enforcement from individual routes --- src/plugin/ssp.ts | 50 ++++ src/routes/aggregate/aggregate.ts | 5 +- src/routes/custom-queries/custom-queries.ts | 6 +- src/routes/operations/delete.ts | 6 +- src/routes/operations/edit.ts | 8 +- src/routes/operations/get-all.ts | 6 +- src/routes/operations/index-route.ts | 6 +- src/routes/operations/post.ts | 6 +- src/routes/operations/search.ts | 6 +- src/server.ts | 2 + src/types/fastify.d.ts | 1 + src/utils/ssp.ts | 36 --- tests/helpers/test-app.ts | 2 + tests/plugin/ssp.test.ts | 313 ++++++++++++++++++++ tests/server.test.ts | 4 +- tests/utils/ssp.test.ts | 182 ------------ 16 files changed, 379 insertions(+), 260 deletions(-) create mode 100644 src/plugin/ssp.ts delete mode 100644 src/utils/ssp.ts create mode 100644 tests/plugin/ssp.test.ts delete mode 100644 tests/utils/ssp.test.ts diff --git a/src/plugin/ssp.ts b/src/plugin/ssp.ts new file mode 100644 index 0000000..2dd73eb --- /dev/null +++ b/src/plugin/ssp.ts @@ -0,0 +1,50 @@ +import {FastifyInstance, FastifyRequest} from 'fastify'; +import fp from 'fastify-plugin'; + +import {SspConfig, SspParamType} from '@/interfaces/config'; + +export default fp( + async (fastify: FastifyInstance) => { + function enforceSSP(request: FastifyRequest): void { + const apiIdentifier = request.routeOptions?.config?.apiIdentifier; + if (!apiIdentifier) { + return; + } + + const ssps: SspConfig[] = + fastify.appConfig.apis?.[apiIdentifier]?.ssp ?? []; + if (!ssps.length) return; + + const apply = (val: unknown, type: SspParamType) => { + if (val && typeof val === 'object' && !Array.isArray(val)) { + const record = val as Record; + ssps.forEach(ssp => { + if (ssp.paramType === type) { + if (ssp.value === '[userId]') { + record[ssp.paramName] = request.user?.id; + } else if (ssp.value === '[userEmail]') { + record[ssp.paramName] = request.user?.email; + } else { + record[ssp.paramName] = ssp.value; + } + } + }); + } + }; + + // 1. replace request.query values based on ssp + apply(request.query, 'query'); + + // 2. replace request.body + apply(request.body, 'body'); + + // 3. finally request.params (path params) + apply(request.params, 'path'); + } + + fastify.decorate('enforceSSP', enforceSSP); + }, + { + name: 'ssp-plugin', + }, +); diff --git a/src/routes/aggregate/aggregate.ts b/src/routes/aggregate/aggregate.ts index efbf5b2..d04c6bb 100644 --- a/src/routes/aggregate/aggregate.ts +++ b/src/routes/aggregate/aggregate.ts @@ -9,7 +9,6 @@ import { SupportedAggregationOperation, } from '@/interfaces/config'; -import {enforceSSP} from '@/utils/ssp'; import {capitalizeFirstLetter} from '@/utils/string'; /** @@ -40,8 +39,6 @@ export function registerAggregateRoutes( // construct the api identifier const apiIdentifier = `aggregateAPIs->${model.name}->${field.name}->getAggregation`; - // extract the api configs based on the api identifier - const sspConfig = config.apis?.[apiIdentifier]?.ssp ?? []; // calculating the authroization based on auth flag, it can be true // if the api level auth is enabled, or if the app level auth is enabled const authorization = @@ -82,7 +79,7 @@ export function registerAggregateRoutes( ); } } - enforceSSP(sspConfig, request); + app.enforceSSP(request); }, preHandler: async request => { await app.callWebhook('request', request, null); diff --git a/src/routes/custom-queries/custom-queries.ts b/src/routes/custom-queries/custom-queries.ts index 2dd0c33..d7eb862 100644 --- a/src/routes/custom-queries/custom-queries.ts +++ b/src/routes/custom-queries/custom-queries.ts @@ -7,8 +7,6 @@ import { import {AppConfig, DataType} from '@/interfaces/config'; -import {enforceSSP} from '@/utils/ssp'; - type ParamSource = { body?: Record; params?: Record; @@ -92,8 +90,6 @@ export function registerCustomQueryRoutes( // constructing the api identifier const apiIdentifier = `customAPIs->customQueries->all->${cq.name}`; - // extracting the api configs based on the api identifier - const sspConfig = config.apis?.[apiIdentifier]?.ssp ?? []; // calculating the authroization based on auth flag, it can be true // if the api level auth is enabled, or if the app level auth is enabled const authorization = @@ -250,7 +246,7 @@ export function registerCustomQueryRoutes( ); } } - enforceSSP(sspConfig, request); + app.enforceSSP(request); }, preHandler: async request => { await app.callWebhook('request', request, null); diff --git a/src/routes/operations/delete.ts b/src/routes/operations/delete.ts index 9d1f4a0..2bf8f51 100644 --- a/src/routes/operations/delete.ts +++ b/src/routes/operations/delete.ts @@ -7,7 +7,6 @@ import { import {AppConfig, ModelConfig, ModelFieldConfig} from '@/interfaces/config'; -import {enforceSSP} from '@/utils/ssp'; import {capitalizeFirstLetter} from '@/utils/string'; /** @@ -39,9 +38,6 @@ export function registerDeleteRoutes( // constructing the api identifier const apiIdentifier = `modelAPIs->${model.name}->${field.name}->delete`; - // extracting the api configs based on the api identifier - const sspConfig = config.apis?.[apiIdentifier]?.ssp ?? []; - // calculating the authroization based on auth flag, it can be true // if the api level auth is enabled, or if the app level auth is enabled const authorization = @@ -77,7 +73,7 @@ export function registerDeleteRoutes( ); } } - enforceSSP(sspConfig, request); + app.enforceSSP(request); }, preHandler: async request => { await app.callWebhook('request', request, null); diff --git a/src/routes/operations/edit.ts b/src/routes/operations/edit.ts index a90b1c1..7ae49dd 100644 --- a/src/routes/operations/edit.ts +++ b/src/routes/operations/edit.ts @@ -9,7 +9,6 @@ import { import {AppConfig, ModelBody} from '@/interfaces/config'; -import {enforceSSP} from '@/utils/ssp'; import {capitalizeFirstLetter} from '@/utils/string'; /** @@ -41,9 +40,6 @@ export function registerEditRoutes( // constructing the api identifier const apiIdentifier = `modelAPIs->${model.name}->${field.name}->edit`; - // extracting the api configs based on the api identifier - const sspConfig = config.apis?.[apiIdentifier]?.ssp ?? []; - // calculating the authroization based on auth flag, it can be true // if the api level auth is enabled, or if the app level auth is enabled const authorization = @@ -244,7 +240,7 @@ export function registerEditRoutes( ); } } - enforceSSP(sspConfig, request); + app.enforceSSP(request); }, preHandler: async request => { await app.callWebhook('request', request, null); @@ -277,7 +273,7 @@ export function registerEditRoutes( ); } } - enforceSSP(sspConfig, request); + app.enforceSSP(request); }, preHandler: async request => { await app.callWebhook('request', request, null); diff --git a/src/routes/operations/get-all.ts b/src/routes/operations/get-all.ts index 2f0bf0d..d3cd176 100644 --- a/src/routes/operations/get-all.ts +++ b/src/routes/operations/get-all.ts @@ -11,7 +11,6 @@ import { import {AppConfig, ModelConfig} from '@/interfaces/config'; -import {enforceSSP} from '@/utils/ssp'; import {capitalizeFirstLetter} from '@/utils/string'; /** @@ -37,9 +36,6 @@ export function registerGetAllRoutes( // constructing the api identifier const apiIdentifier = `modelAPIs->${model.name}->all->getAll`; - // extracting the api configs based on the api identifier - const sspConfig = config.apis?.[apiIdentifier]?.ssp ?? []; - // calculating the authroization based on auth flag, it can be true // if the api level auth is enabled, or if the app level auth is enabled const authorization = @@ -74,7 +70,7 @@ export function registerGetAllRoutes( ); } } - enforceSSP(sspConfig, request); + app.enforceSSP(request); }, preHandler: async request => { await app.callWebhook('request', request, null); diff --git a/src/routes/operations/index-route.ts b/src/routes/operations/index-route.ts index 01645a0..b7d7849 100644 --- a/src/routes/operations/index-route.ts +++ b/src/routes/operations/index-route.ts @@ -12,7 +12,6 @@ import { import {AppConfig, ModelConfig, ModelFieldConfig} from '@/interfaces/config'; -import {enforceSSP} from '@/utils/ssp'; import {capitalizeFirstLetter} from '@/utils/string'; /** @@ -49,9 +48,6 @@ export function registerIndexRoutes( // constructing the api identifier const apiIdentifier = `modelAPIs->${model.name}->${field.name}->index`; - // extracting the api configs based on the api identifier - const sspConfig = config.apis?.[apiIdentifier]?.ssp ?? []; - // calculating the authroization based on auth flag, it can be true // if the api level auth is enabled, or if the app level auth is enabled const authorization = @@ -85,7 +81,7 @@ export function registerIndexRoutes( ); } } - enforceSSP(sspConfig, request); + app.enforceSSP(request); }, preHandler: async request => { await app.callWebhook('request', request, null); diff --git a/src/routes/operations/post.ts b/src/routes/operations/post.ts index 5420f63..c7c2459 100644 --- a/src/routes/operations/post.ts +++ b/src/routes/operations/post.ts @@ -8,7 +8,6 @@ import { import {AppConfig, ModelBody, ModelConfig} from '@/interfaces/config'; -import {enforceSSP} from '@/utils/ssp'; import {capitalizeFirstLetter} from '@/utils/string'; /** @@ -29,9 +28,6 @@ export function registerPostRoutes( // constructing the api identifier const apiIdentifier = `modelAPIs->${model.name}->all->insert`; - // extracting the api configs based on the api identifier - const sspConfig = config.apis?.[apiIdentifier]?.ssp ?? []; - // calculating the authroization based on auth flag, it can be true // if the api level auth is enabled, or if the app level auth is enabled const authorization = @@ -69,7 +65,7 @@ export function registerPostRoutes( ); } } - enforceSSP(sspConfig, request); + app.enforceSSP(request); }, preHandler: async request => { await app.callWebhook('request', request, null); diff --git a/src/routes/operations/search.ts b/src/routes/operations/search.ts index bcb36f1..de21f87 100644 --- a/src/routes/operations/search.ts +++ b/src/routes/operations/search.ts @@ -11,7 +11,6 @@ import { import {AppConfig, ModelConfig, ModelFieldConfig} from '@/interfaces/config'; -import {enforceSSP} from '@/utils/ssp'; import {capitalizeFirstLetter} from '@/utils/string'; /** @@ -41,9 +40,6 @@ export function registerSearchRoutes( // constructing the api identifier const apiIdentifier = `modelAPIs->${model.name}->${field.name}->search`; - // extracting the api configs based on the api identifier - const sspConfig = config.apis?.[apiIdentifier]?.ssp ?? []; - // calculating the authroization based on auth flag, it can be true // if the api level auth is enabled, or if the app level auth is enabled const authorization = @@ -81,7 +77,7 @@ export function registerSearchRoutes( ); } } - enforceSSP(sspConfig, request); + app.enforceSSP(request); }, preHandler: async request => { await app.callWebhook('request', request, null); diff --git a/src/server.ts b/src/server.ts index 9831a77..43357c0 100644 --- a/src/server.ts +++ b/src/server.ts @@ -13,6 +13,7 @@ import dbPlugin from '@/plugin/database'; import rateLimitPlugin from '@/plugin/rate-limit'; import redisPlugin from '@/plugin/redis'; import responsePlugin from '@/plugin/response'; +import sspPlugin from '@/plugin/ssp'; import webhookPlugin from '@/plugin/webhook'; import {registerRoutes} from '@/routes'; @@ -135,6 +136,7 @@ export async function startServer( } await app.register(responsePlugin); + await app.register(sspPlugin); await app.register(webhookPlugin); if (config.auth) { await app.register(authPlugin); diff --git a/src/types/fastify.d.ts b/src/types/fastify.d.ts index 9d8c6a9..e64c789 100644 --- a/src/types/fastify.d.ts +++ b/src/types/fastify.d.ts @@ -35,5 +35,6 @@ declare module 'fastify' { request: import('fastify').FastifyRequest, payload: unknown, ) => Promise; + enforceSSP: (request: import('fastify').FastifyRequest) => void; } } diff --git a/src/utils/ssp.ts b/src/utils/ssp.ts deleted file mode 100644 index 1a488b2..0000000 --- a/src/utils/ssp.ts +++ /dev/null @@ -1,36 +0,0 @@ -import {FastifyRequest} from 'fastify'; - -import {SspConfig, SspParamType} from '@/interfaces/config'; - -/** - * Apply SSP values to the request based on the ssp config. - */ -export function enforceSSP(ssps: SspConfig[], request: FastifyRequest) { - const apply = (val: unknown, type: SspParamType) => { - if (val && typeof val === 'object' && !Array.isArray(val)) { - const record = val as Record; - ssps.forEach(ssp => { - if (ssp.paramType === type) { - // if ssp.value is a magic variable then replace it with request.user - // aloowed magic variables are [userId] and [userEmail] - if (ssp.value === '[userId]') { - record[ssp.paramName] = request.user?.id; - } else if (ssp.value === '[userEmail]') { - record[ssp.paramName] = request.user?.email; - } else { - record[ssp.paramName] = ssp.value; - } - } - }); - } - }; - - // 1. replace request.query values based on spp - apply(request.query, 'query'); - - // 2. replace request.body - apply(request.body, 'body'); - - // 3. finally request.path (path params) - apply(request.params, 'path'); -} diff --git a/tests/helpers/test-app.ts b/tests/helpers/test-app.ts index dd22c91..82a4c3f 100644 --- a/tests/helpers/test-app.ts +++ b/tests/helpers/test-app.ts @@ -3,6 +3,7 @@ import Fastify, {FastifyInstance} from 'fastify'; import authPlugin from '@/plugin/auth'; import databasePlugin from '@/plugin/database'; import responsePlugin from '@/plugin/response'; +import sspPlugin from '@/plugin/ssp'; import webhookPlugin from '@/plugin/webhook'; import {registerRoutes} from '@/routes'; @@ -67,6 +68,7 @@ export async function createTestApp( await fastify.register(databasePlugin, dbConfig); await fastify.register(responsePlugin); + await fastify.register(sspPlugin); await fastify.register(webhookPlugin); await fastify.register(authPlugin); diff --git a/tests/plugin/ssp.test.ts b/tests/plugin/ssp.test.ts new file mode 100644 index 0000000..481fba7 --- /dev/null +++ b/tests/plugin/ssp.test.ts @@ -0,0 +1,313 @@ +import Fastify, {FastifyRequest} from 'fastify'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +import sspPlugin from '@/plugin/ssp'; + +import {AppConfig, SspConfig} from '@/interfaces/config'; + +describe('ssp plugin', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('ssp plugin decorates fastify with enforceSSP', async () => { + const app = Fastify(); + app.appConfig = {application: {logLevel: 'silent'}} as unknown as AppConfig; + await app.register(sspPlugin); + await app.ready(); + + expect(app.hasDecorator('enforceSSP')).toBe(true); + }); + + it('should not modify the request if apiIdentifier is missing', async () => { + const app = Fastify(); + app.appConfig = {application: {logLevel: 'silent'}} as unknown as AppConfig; + await app.register(sspPlugin); + await app.ready(); + + const request = { + query: {foo: 'bar'}, + body: {baz: 'qux'}, + params: {id: '1'}, + routeOptions: { + config: {}, + }, + } as unknown as FastifyRequest; + + app.enforceSSP(request); + + expect(request.query).toEqual({foo: 'bar'}); + expect(request.body).toEqual({baz: 'qux'}); + expect(request.params).toEqual({id: '1'}); + }); + + it('should not modify request if no ssp configs exist for the API', async () => { + const app = Fastify(); + app.appConfig = { + application: {logLevel: 'silent'}, + apis: { + 'test-api': { + ssp: [], + }, + }, + } as unknown as AppConfig; + await app.register(sspPlugin); + await app.ready(); + + const request = { + query: {foo: 'bar'}, + body: {baz: 'qux'}, + params: {id: '1'}, + routeOptions: { + config: { + apiIdentifier: 'test-api', + }, + }, + } as unknown as FastifyRequest; + + app.enforceSSP(request); + + expect(request.query).toEqual({foo: 'bar'}); + expect(request.body).toEqual({baz: 'qux'}); + expect(request.params).toEqual({id: '1'}); + }); + + it('should add SSP values to query, body, and params if missing', async () => { + const app = Fastify(); + const ssps: SspConfig[] = [ + {paramType: 'query', paramName: 'qParam', value: 'qValue'}, + {paramType: 'body', paramName: 'bParam', value: 123}, + {paramType: 'path', paramName: 'pParam', value: true}, + ]; + + app.appConfig = { + application: {logLevel: 'silent'}, + apis: { + 'test-api': { + ssp: ssps, + }, + }, + } as unknown as AppConfig; + await app.register(sspPlugin); + await app.ready(); + + const request = { + query: {foo: 'bar'}, + body: {baz: 'qux'}, + params: {id: '1'}, + routeOptions: { + config: { + apiIdentifier: 'test-api', + }, + }, + } as unknown as FastifyRequest; + + app.enforceSSP(request); + + expect(request.query).toEqual({foo: 'bar', qParam: 'qValue'}); + expect(request.body).toEqual({baz: 'qux', bParam: 123}); + expect(request.params).toEqual({id: '1', pParam: true}); + }); + + it('should overwrite existing values in query, body, and params', async () => { + const app = Fastify(); + const ssps: SspConfig[] = [ + {paramType: 'query', paramName: 'tenantId', value: 'newTenant'}, + {paramType: 'body', paramName: 'userId', value: 'newUser'}, + {paramType: 'path', paramName: 'groupId', value: 'newGroup'}, + ]; + + app.appConfig = { + application: {logLevel: 'silent'}, + apis: { + 'test-api': { + ssp: ssps, + }, + }, + } as unknown as AppConfig; + await app.register(sspPlugin); + await app.ready(); + + const request = { + query: {tenantId: 'oldTenant'}, + body: {userId: 'oldUser'}, + params: {groupId: 'oldGroup'}, + routeOptions: { + config: { + apiIdentifier: 'test-api', + }, + }, + } as unknown as FastifyRequest; + + app.enforceSSP(request); + + expect(request.query).toEqual({tenantId: 'newTenant'}); + expect(request.body).toEqual({userId: 'newUser'}); + expect(request.params).toEqual({groupId: 'newGroup'}); + }); + + it('should handle missing request properties gracefully', async () => { + const app = Fastify(); + const ssps: SspConfig[] = [ + {paramType: 'query', paramName: 'tenantId', value: 'newTenant'}, + ]; + + app.appConfig = { + application: {logLevel: 'silent'}, + apis: { + 'test-api': { + ssp: ssps, + }, + }, + } as unknown as AppConfig; + await app.register(sspPlugin); + await app.ready(); + + const request = { + routeOptions: { + config: { + apiIdentifier: 'test-api', + }, + }, + } as unknown as FastifyRequest; + + expect(() => app.enforceSSP(request)).not.toThrow(); + expect(request.query).toBeUndefined(); + }); + + it('should not apply SSPs if the target property is an array', async () => { + const app = Fastify(); + const ssps: SspConfig[] = [ + {paramType: 'query', paramName: 'tenantId', value: 'newTenant'}, + {paramType: 'body', paramName: 'userId', value: 'newUser'}, + {paramType: 'path', paramName: 'groupId', value: 'newGroup'}, + ]; + + app.appConfig = { + application: {logLevel: 'silent'}, + apis: { + 'test-api': { + ssp: ssps, + }, + }, + } as unknown as AppConfig; + await app.register(sspPlugin); + await app.ready(); + + const request = { + query: ['not', 'an', 'object'], + body: [{item: 1}, {item: 2}], + params: ['param1', 'param2'], + routeOptions: { + config: { + apiIdentifier: 'test-api', + }, + }, + } as unknown as FastifyRequest; + + app.enforceSSP(request); + + expect(request.query).toEqual(['not', 'an', 'object']); + expect(request.body).toEqual([{item: 1}, {item: 2}]); + expect(request.params).toEqual(['param1', 'param2']); + }); + + it('should replace [userId] magic variable with request.user.id', async () => { + const app = Fastify(); + const ssps: SspConfig[] = [ + {paramType: 'query', paramName: 'ownerId', value: '[userId]'}, + ]; + + app.appConfig = { + application: {logLevel: 'silent'}, + apis: { + 'test-api': { + ssp: ssps, + }, + }, + } as unknown as AppConfig; + await app.register(sspPlugin); + await app.ready(); + + const request = { + query: {}, + user: {id: 42, email: 'test@example.com'}, + routeOptions: { + config: { + apiIdentifier: 'test-api', + }, + }, + } as unknown as FastifyRequest; + + app.enforceSSP(request); + + expect(request.query).toEqual({ownerId: 42}); + }); + + it('should replace [userEmail] magic variable with request.user.email', async () => { + const app = Fastify(); + const ssps: SspConfig[] = [ + {paramType: 'body', paramName: 'user_email', value: '[userEmail]'}, + ]; + + app.appConfig = { + application: {logLevel: 'silent'}, + apis: { + 'test-api': { + ssp: ssps, + }, + }, + } as unknown as AppConfig; + await app.register(sspPlugin); + await app.ready(); + + const request = { + body: {}, + user: {id: 42, email: 'test@example.com'}, + routeOptions: { + config: { + apiIdentifier: 'test-api', + }, + }, + } as unknown as FastifyRequest; + + app.enforceSSP(request); + + expect(request.body).toEqual({user_email: 'test@example.com'}); + }); + + it('should handle missing request.user when magic variables are used', async () => { + const app = Fastify(); + const ssps: SspConfig[] = [ + {paramType: 'query', paramName: 'ownerId', value: '[userId]'}, + ]; + + app.appConfig = { + application: {logLevel: 'silent'}, + apis: { + 'test-api': { + ssp: ssps, + }, + }, + } as unknown as AppConfig; + await app.register(sspPlugin); + await app.ready(); + + const request = { + query: {}, + routeOptions: { + config: { + apiIdentifier: 'test-api', + }, + }, + } as unknown as FastifyRequest; + + app.enforceSSP(request); + + expect(request.query).toEqual({ownerId: undefined}); + }); +}); diff --git a/tests/server.test.ts b/tests/server.test.ts index 878d237..dc580c1 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -214,7 +214,7 @@ describe('Server', () => { it('should register plugins and routes', async () => { await runStart('dev', false, true); - expect(mockApp.register).toHaveBeenCalledTimes(5); + expect(mockApp.register).toHaveBeenCalledTimes(6); expect(migrateDatabase).toHaveBeenCalledWith(mockConfig); expect(registerRoutes).toHaveBeenCalledWith(mockApp, mockConfig); @@ -232,7 +232,7 @@ describe('Server', () => { } as unknown as AppConfig; await startServer(disabledSwaggerConfig, 3000, 'prod'); - expect(mockApp.register).toHaveBeenCalledTimes(3); + expect(mockApp.register).toHaveBeenCalledTimes(4); }); it('should not register routes if models are missing/empty', async () => { diff --git a/tests/utils/ssp.test.ts b/tests/utils/ssp.test.ts deleted file mode 100644 index b72406e..0000000 --- a/tests/utils/ssp.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -import {FastifyRequest} from 'fastify'; -import {describe, expect, it} from 'vitest'; - -import {SspConfig} from '@/interfaces/config'; - -import {enforceSSP} from '@/utils/ssp'; - -describe('enforceSSP', () => { - it('should not modify the request if ssps array is empty', () => { - const request = { - query: {foo: 'bar'}, - body: {baz: 'qux'}, - params: {id: '1'}, - } as unknown as FastifyRequest; - - enforceSSP([], request); - - expect(request.query).toEqual({foo: 'bar'}); - expect(request.body).toEqual({baz: 'qux'}); - expect(request.params).toEqual({id: '1'}); - }); - - it('should add SSP values to query, body, and params if missing', () => { - const request = { - query: {foo: 'bar'}, - body: {baz: 'qux'}, - params: {id: '1'}, - } as unknown as FastifyRequest; - - const ssps: SspConfig[] = [ - {paramType: 'query', paramName: 'qParam', value: 'qValue'}, - {paramType: 'body', paramName: 'bParam', value: 123}, - {paramType: 'path', paramName: 'pParam', value: true}, - ]; - - enforceSSP(ssps, request); - - expect(request.query).toEqual({foo: 'bar', qParam: 'qValue'}); - expect(request.body).toEqual({baz: 'qux', bParam: 123}); - expect(request.params).toEqual({id: '1', pParam: true}); - }); - - it('should overwrite existing values in query, body, and params', () => { - const request = { - query: {tenantId: 'oldTenant'}, - body: {userId: 'oldUser'}, - params: {groupId: 'oldGroup'}, - } as unknown as FastifyRequest; - - const ssps: SspConfig[] = [ - {paramType: 'query', paramName: 'tenantId', value: 'newTenant'}, - {paramType: 'body', paramName: 'userId', value: 'newUser'}, - {paramType: 'path', paramName: 'groupId', value: 'newGroup'}, - ]; - - enforceSSP(ssps, request); - - expect(request.query).toEqual({tenantId: 'newTenant'}); - expect(request.body).toEqual({userId: 'newUser'}); - expect(request.params).toEqual({groupId: 'newGroup'}); - }); - - it('should handle missing request properties gracefully (undefined/null)', () => { - const request = { - // query, body, and params are missing/undefined - } as unknown as FastifyRequest; - - const ssps: SspConfig[] = [ - {paramType: 'query', paramName: 'tenantId', value: 'newTenant'}, - ]; - - expect(() => enforceSSP(ssps, request)).not.toThrow(); - // Since they are undefined, enforceSSP should just ignore them and not mutate or throw - expect(request.query).toBeUndefined(); - }); - - it('should not apply SSPs if the target property is an array', () => { - const request = { - query: ['not', 'an', 'object'], - body: [{item: 1}, {item: 2}], - params: ['param1', 'param2'], - } as unknown as FastifyRequest; - - const ssps: SspConfig[] = [ - {paramType: 'query', paramName: 'tenantId', value: 'newTenant'}, - {paramType: 'body', paramName: 'userId', value: 'newUser'}, - {paramType: 'path', paramName: 'groupId', value: 'newGroup'}, - ]; - - enforceSSP(ssps, request); - - // Arrays should remain untouched, not converted to objects or mutated with string keys - expect(request.query).toEqual(['not', 'an', 'object']); - expect(request.body).toEqual([{item: 1}, {item: 2}]); - expect(request.params).toEqual(['param1', 'param2']); - }); - - it('should handle applying multiple SSPs to the same target type', () => { - const request = { - query: {foo: 'bar'}, - body: {}, - params: {}, - } as unknown as FastifyRequest; - - const ssps: SspConfig[] = [ - {paramType: 'query', paramName: 'param1', value: 'val1'}, - {paramType: 'query', paramName: 'param2', value: 'val2'}, - {paramType: 'query', paramName: 'param3', value: 'val3'}, - ]; - - enforceSSP(ssps, request); - - expect(request.query).toEqual({ - foo: 'bar', - param1: 'val1', - param2: 'val2', - param3: 'val3', - }); - }); - - it('should apply the last SSP value if multiple SSPs have the same paramName and paramType', () => { - const request = { - query: {}, - body: {}, - params: {}, - } as unknown as FastifyRequest; - - const ssps: SspConfig[] = [ - {paramType: 'query', paramName: 'duplicateKey', value: 'firstValue'}, - {paramType: 'query', paramName: 'duplicateKey', value: 'secondValue'}, - ]; - - enforceSSP(ssps, request); - - expect(request.query).toEqual({duplicateKey: 'secondValue'}); - }); - - it('should replace [userId] magic variable with request.user.id', () => { - const request = { - query: {}, - user: {id: 42, email: 'test@example.com'}, - } as unknown as FastifyRequest; - - const ssps: SspConfig[] = [ - {paramType: 'query', paramName: 'ownerId', value: '[userId]'}, - ]; - - enforceSSP(ssps, request); - - expect(request.query).toEqual({ownerId: 42}); - }); - - it('should replace [userEmail] magic variable with request.user.email', () => { - const request = { - body: {}, - user: {id: 42, email: 'test@example.com'}, - } as unknown as FastifyRequest; - - const ssps: SspConfig[] = [ - {paramType: 'body', paramName: 'user_email', value: '[userEmail]'}, - ]; - - enforceSSP(ssps, request); - - expect(request.body).toEqual({user_email: 'test@example.com'}); - }); - - it('should handle missing request.user when magic variables are used', () => { - const request = { - query: {}, - // no user object - } as unknown as FastifyRequest; - - const ssps: SspConfig[] = [ - {paramType: 'query', paramName: 'ownerId', value: '[userId]'}, - ]; - - enforceSSP(ssps, request); - - expect(request.query).toEqual({ownerId: undefined}); - }); -});