mirror of https://github.com/ghostfolio/ghostfolio
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
168 lines
4.7 KiB
168 lines
4.7 KiB
import {
|
|
DynamicModule,
|
|
ForwardReference,
|
|
RequestMethod,
|
|
Type
|
|
} from '@nestjs/common';
|
|
import {
|
|
GUARDS_METADATA,
|
|
METHOD_METADATA,
|
|
MODULE_METADATA,
|
|
PATH_METADATA
|
|
} from '@nestjs/common/constants';
|
|
import { AuthGuard } from '@nestjs/passport';
|
|
|
|
import { AppModule } from './app.module';
|
|
|
|
// The packages are ECMAScript modules, which Jest cannot load
|
|
jest.mock('@openrouter/ai-sdk-provider', () => {
|
|
return {};
|
|
});
|
|
jest.mock('ai', () => {
|
|
return {};
|
|
});
|
|
|
|
type ModuleDefinition =
|
|
DynamicModule | ForwardReference<() => Type> | Promise<DynamicModule> | Type;
|
|
|
|
/**
|
|
* The routes which answer a request without the authentication of a user or
|
|
* of an API key, sorted by the request method and the path
|
|
*/
|
|
const PUBLIC_ROUTES = [
|
|
'GET /asset/:dataSource/:symbol',
|
|
'GET /assets/:languageCode/site.webmanifest',
|
|
'GET /auth/google',
|
|
'GET /auth/google/callback',
|
|
'GET /auth/oidc',
|
|
'GET /auth/oidc/callback',
|
|
'GET /benchmarks',
|
|
'GET /health',
|
|
'GET /health/ai',
|
|
'GET /health/data-enhancer/:name',
|
|
'GET /health/data-provider/:dataSource',
|
|
'GET /info',
|
|
'GET /logo',
|
|
'GET /logo/:dataSource/:symbol',
|
|
'GET /public/:accessId/portfolio',
|
|
'GET /sitemap.xml',
|
|
'GET /subscription/stripe/callback',
|
|
'POST /auth/anonymous',
|
|
'POST /auth/webauthn/generate-authentication-options',
|
|
'POST /auth/webauthn/verify-authentication',
|
|
'POST /user'
|
|
];
|
|
|
|
/**
|
|
* Gives the controllers of the module and of each module which it imports
|
|
*/
|
|
async function getControllers(
|
|
moduleDefinition: ModuleDefinition,
|
|
visitedModules = new Set<DynamicModule | Type>()
|
|
): Promise<Type[]> {
|
|
const resolvedModule = await ('forwardRef' in moduleDefinition
|
|
? moduleDefinition.forwardRef()
|
|
: moduleDefinition);
|
|
|
|
if (visitedModules.has(resolvedModule)) {
|
|
return [];
|
|
}
|
|
|
|
visitedModules.add(resolvedModule);
|
|
|
|
const isDynamicModule = 'module' in resolvedModule;
|
|
|
|
const controllers = [
|
|
...(isDynamicModule
|
|
? (resolvedModule.controllers ?? [])
|
|
: ((Reflect.getMetadata(MODULE_METADATA.CONTROLLERS, resolvedModule) ??
|
|
[]) as Type[]))
|
|
];
|
|
|
|
const importedModules = isDynamicModule
|
|
? [resolvedModule.module, ...(resolvedModule.imports ?? [])]
|
|
: ((Reflect.getMetadata(MODULE_METADATA.IMPORTS, resolvedModule) ??
|
|
[]) as ModuleDefinition[]);
|
|
|
|
for (const importedModule of importedModules) {
|
|
controllers.push(...(await getControllers(importedModule, visitedModules)));
|
|
}
|
|
|
|
return controllers;
|
|
}
|
|
|
|
/**
|
|
* Gives the paths which the decorator of a controller or of a route sets
|
|
*/
|
|
function getPaths(target: object) {
|
|
return [
|
|
(Reflect.getMetadata(PATH_METADATA, target) ?? '') as string | string[]
|
|
].flat();
|
|
}
|
|
|
|
/**
|
|
* Gives the routes of the controller which apply neither AuthGuard('api-key')
|
|
* nor AuthGuard('jwt'), each as the request method and the path
|
|
*/
|
|
function getRoutesWithoutAuthentication(controller: Type) {
|
|
const authenticationGuards: unknown[] = [
|
|
AuthGuard('api-key'),
|
|
AuthGuard('jwt')
|
|
];
|
|
const routeHandlersByName = controller.prototype as Record<string, object>;
|
|
|
|
return Object.getOwnPropertyNames(routeHandlersByName).flatMap(
|
|
(methodName) => {
|
|
const routeHandler = routeHandlersByName[methodName];
|
|
const requestMethod = Reflect.getMetadata(
|
|
METHOD_METADATA,
|
|
routeHandler
|
|
) as RequestMethod | undefined;
|
|
|
|
if (requestMethod === undefined) {
|
|
return [];
|
|
}
|
|
|
|
const guards = [controller, routeHandler].flatMap((target) => {
|
|
return (Reflect.getMetadata(GUARDS_METADATA, target) ??
|
|
[]) as unknown[];
|
|
});
|
|
|
|
const hasAuthentication = guards.some((guard) => {
|
|
return authenticationGuards.includes(guard);
|
|
});
|
|
|
|
if (hasAuthentication) {
|
|
return [];
|
|
}
|
|
|
|
return getPaths(controller).flatMap((controllerPath) => {
|
|
return getPaths(routeHandler).map((routePath) => {
|
|
const path = `${controllerPath}/${routePath}`
|
|
.split('/')
|
|
.filter(Boolean)
|
|
.join('/');
|
|
|
|
return `${RequestMethod[requestMethod]} /${path}`;
|
|
});
|
|
});
|
|
}
|
|
);
|
|
}
|
|
|
|
describe('AppModule', () => {
|
|
// A route which applies neither AuthGuard('api-key') nor AuthGuard('jwt')
|
|
// (e.g. via the decorator RequiresScope) is open to every request, hence a
|
|
// new route has to apply one of them or has to be added to the public routes
|
|
it('should require the authentication for each route which is not public', async () => {
|
|
const controllers = await getControllers(AppModule);
|
|
|
|
const routesWithoutAuthentication = controllers
|
|
.flatMap((controller) => {
|
|
return getRoutesWithoutAuthentication(controller);
|
|
})
|
|
.sort();
|
|
|
|
expect(routesWithoutAuthentication).toEqual(PUBLIC_ROUTES);
|
|
});
|
|
});
|
|
|