Skip to content

Commit

Permalink
feat(core): plugins can register an unprotected route
Browse files Browse the repository at this point in the history
With great power comes great responsibility
  • Loading branch information
TdyP committed Dec 2, 2024
1 parent 0aa690f commit eb8a2cf
Show file tree
Hide file tree
Showing 3 changed files with 139 additions and 28 deletions.
7 changes: 6 additions & 1 deletion apps/core/src/_types/endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,9 @@ import {RequestHandler} from 'express';
*/
export type ExpressAppMethod = 'all' | 'get' | 'post' | 'put' | 'delete' | 'patch' | 'options' | 'head';

export type PluginRegisterRoute<T = any> = [path: string, method: ExpressAppMethod, handlers: Array<RequestHandler<T>>];
export type PluginRegisterRoute<T = any> = [
path: string,
method: ExpressAppMethod,
handlers: Array<RequestHandler<T>>,
isProtected?: boolean
];
100 changes: 89 additions & 11 deletions apps/core/src/app/endpoint/endpointApp.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,23 @@
import initQueryContext from '../helpers/initQueryContext';
import type {ValidateRequestTokenFunc} from '../helpers/validateRequestToken';
import type {Express} from 'express';
import createEndpointApp from './endpointApp';
import createEndpointApp, {IPluginRoute} from './endpointApp';
import {IValueDomain} from 'domain/value/valueDomain';
import {IConfig} from '_types/config';

describe('endpointApp', () => {
const validateRequestTokenHelper = jest.fn();
const expressApp: Mockify<Express> = {get: jest.fn(), post: jest.fn()};

const mockRoute = {
path: '/test',
handlers: [jest.fn()],
method: 'get',
isProtected: true
} satisfies IPluginRoute;

beforeEach(() => {
expressApp.get.mockClear();
expressApp.post.mockClear();
jest.clearAllMocks();
});

it('Should expose an extensionPoints.registerRoutes', async () => {
Expand Down Expand Up @@ -43,15 +51,26 @@ describe('endpointApp', () => {
});

describe('_initCtxHandler as the first handler', () => {
const endpointApp = createEndpointApp({
'core.app.helpers.initQueryContext': initQueryContext({}),
'core.app.helpers.validateRequestToken': validateRequestTokenHelper as ValidateRequestTokenFunc
let mockInitQueryContext: jest.Mock;
beforeEach(() => {
mockInitQueryContext = jest.fn().mockReturnValue({
userId: null,
lang: 'fr',
queryId: 'requestId',
groupsId: [],
errors: []
});
});
endpointApp.extensionPoints.registerRoutes([['/protected_endpoint', 'get', [jest.fn()]]]);

it('Should call extends request and call next()', async () => {
it('Should call extends request and call next() if user is authenticated', async () => {
const endpointApp = createEndpointApp({
'core.app.helpers.initQueryContext': mockInitQueryContext,
'core.app.helpers.validateRequestToken': validateRequestTokenHelper as ValidateRequestTokenFunc
});
endpointApp.extensionPoints.registerRoutes([[mockRoute.path, mockRoute.method, mockRoute.handlers]]);

endpointApp.registerRoute(expressApp as unknown as Express);
const [_initCtxHandler, ...ignoredHandlers] = expressApp.get.mock.calls[0][1];
const [_initCtxHandler, ..._ignoredHandlers] = expressApp.get.mock.calls[0][1];
const request = {query: {lang: 'fr'}, body: {requestId: 'requestId'}};
const nextMock = jest.fn();
validateRequestTokenHelper.mockResolvedValue({groupsId: 'groupsId', userId: 'userId'});
Expand All @@ -77,9 +96,15 @@ describe('endpointApp', () => {
expect(nextMock).toHaveBeenCalledWith();
});

it('Should call extends request and call next() with error', async () => {
it('Should call extends request and call next() with error if user is not authenticated', async () => {
const endpointApp = createEndpointApp({
'core.app.helpers.initQueryContext': mockInitQueryContext,
'core.app.helpers.validateRequestToken': validateRequestTokenHelper as ValidateRequestTokenFunc
});
endpointApp.extensionPoints.registerRoutes([[mockRoute.path, mockRoute.method, mockRoute.handlers]]);

endpointApp.registerRoute(expressApp as unknown as Express);
const [_initCtxHandler, ...ignoredHandlers] = expressApp.get.mock.calls[0][1];
const [_initCtxHandler, ..._ignoredHandlers] = expressApp.get.mock.calls[0][1];
const request = {query: {lang: 'fr'}, body: {requestId: 'requestId'}};
const nextMock = jest.fn();
validateRequestTokenHelper.mockRejectedValue('error');
Expand All @@ -101,8 +126,61 @@ describe('endpointApp', () => {
lang: 'fr'
}
});

expect(nextMock).toHaveBeenCalledTimes(1);
expect(nextMock).toHaveBeenCalledWith('error');
});

it('Should call extends request and call next() if route is not protected', async () => {
const mockValueDomain: Mockify<IValueDomain> = {
getValues: global.__mockPromise([
{
payload: {
id: '123456'
}
}
])
};

const mockConfig: Partial<IConfig> = {
defaultUserId: '2'
};

const endpointApp = createEndpointApp({
'core.app.helpers.initQueryContext': mockInitQueryContext,
'core.app.helpers.validateRequestToken': validateRequestTokenHelper as ValidateRequestTokenFunc,
'core.domain.value': mockValueDomain as IValueDomain,
config: mockConfig as IConfig
});

endpointApp.extensionPoints.registerRoutes([[mockRoute.path, mockRoute.method, mockRoute.handlers, false]]);

endpointApp.registerRoute(expressApp as unknown as Express);
const [_initCtxHandler, ..._ignoredHandlers] = expressApp.get.mock.calls[0][1];
const request = {query: {lang: 'fr'}, body: {requestId: 'requestId'}};
const nextMock = jest.fn();

await _initCtxHandler(request, undefined, nextMock);

expect(validateRequestTokenHelper).not.toHaveBeenCalled();

expect(request).toEqual({
body: {
requestId: 'requestId'
},
ctx: {
userId: mockConfig.defaultUserId, // default user id
groupsId: ['123456'],
errors: [],
lang: 'fr',
queryId: 'requestId'
},
query: {
lang: 'fr'
}
});
expect(nextMock).toHaveBeenCalledTimes(1);
expect(nextMock).toHaveBeenCalledWith();
});
});
});
60 changes: 44 additions & 16 deletions apps/core/src/app/endpoint/endpointApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import type {ExpressAppMethod, PluginRegisterRoute} from '../../_types/endpoint'
import type {IRequestWithContext} from '../../_types/express';
import type {InitQueryContextFunc} from '../helpers/initQueryContext';
import type {ValidateRequestTokenFunc} from '../helpers/validateRequestToken';
import {IConfig} from '_types/config';
import {IValueDomain} from 'domain/value/valueDomain';
import {USERS_LIBRARY} from '../../_types/library';
import {ITreeValue} from '_types/value';
import {USERS_GROUP_ATTRIBUTE_NAME} from '../../infra/permission/permissionRepo';

interface IEndpointApp extends IAppModule {
/**
Expand All @@ -16,54 +21,77 @@ interface IEndpointApp extends IAppModule {
registerRoute(app: Express): void;
}

interface IPluginRoute {
export interface IPluginRoute {
path: string;
method: ExpressAppMethod;
/**
* All plugin route handlers should be used after an auth middleware.
* Errors are managed globally by a middleware.
*/
handlers: Array<RequestHandler<any>>;
isProtected?: boolean;
}

interface IDeps {
'core.app.helpers.initQueryContext'?: InitQueryContextFunc;
'core.app.helpers.validateRequestToken'?: ValidateRequestTokenFunc;
'core.domain.value'?: IValueDomain;
config?: IConfig;
}

export default function ({
'core.app.helpers.initQueryContext': initQueryContext = null,
'core.app.helpers.validateRequestToken': validateRequestToken = null
'core.app.helpers.validateRequestToken': validateRequestToken = null,
'core.domain.value': valueDomain = null,
config = null
}: IDeps = {}): IEndpointApp {
const _pluginsRoutes: IPluginRoute[] = [];

return {
registerRoute(app) {
const _initCtxHandler: RequestHandler<any> = async (req: IRequestWithContext, res, next) => {
try {
req.ctx = initQueryContext(req);
const _initCtxHandler =
(route: IPluginRoute) =>
async (req: IRequestWithContext, res, next): Promise<RequestHandler<any>> => {
try {
req.ctx = initQueryContext(req);

const {groupsId, userId} = await validateRequestToken(req, res);
req.ctx.userId = userId;
req.ctx.groupsId = groupsId;
if (route.isProtected ?? true) {
const {groupsId, userId} = await validateRequestToken(req, res);
req.ctx.userId = userId;
req.ctx.groupsId = groupsId;
} else {
req.ctx.userId = config.defaultUserId;

return next();
} catch (err) {
return next(err);
}
};
// Fetch user groups
const userGroups = (await valueDomain.getValues({
library: USERS_LIBRARY,
recordId: req.ctx.userId,
attribute: USERS_GROUP_ATTRIBUTE_NAME,
ctx: req.ctx
})) as ITreeValue[];
const groupsId = userGroups.map(g => g.payload?.id);

req.ctx.groupsId = groupsId;
}

return next();
} catch (err) {
return next(err);
}
};

_pluginsRoutes.forEach(route => {
app[route.method](route.path, [_initCtxHandler, ...route.handlers]);
app[route.method](route.path, [_initCtxHandler(route), ...route.handlers]);
});
},
extensionPoints: {
registerRoutes: (routes: PluginRegisterRoute[]) => {
_pluginsRoutes.push(
...routes.map<IPluginRoute>(([path, method, handlers]) => ({
...routes.map<IPluginRoute>(([path, method, handlers, isProtected]) => ({
path,
method,
handlers
handlers,
isProtected
}))
);
}
Expand Down

0 comments on commit eb8a2cf

Please sign in to comment.