Browse Source

Task/extend app module spec by route snapshot (#8227)

Extend app module spec by route snapshot
pull/8243/head
Thomas Kaul 4 days ago
committed by GitHub
parent
commit
7e54ba6592
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1183
      apps/api/src/app/__snapshots__/app.module.spec.ts.snap
  2. 212
      apps/api/src/app/app.module.spec.ts

1183
apps/api/src/app/__snapshots__/app.module.spec.ts.snap

File diff suppressed because it is too large

212
apps/api/src/app/app.module.spec.ts

@ -1,17 +1,26 @@
import { ALLOW_DURING_IMPERSONATION_KEY } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator';
import { HAS_PERMISSION_KEY } from '@ghostfolio/api/decorators/has-permission.decorator';
import { REQUIRES_SCOPE_KEY } from '@ghostfolio/api/decorators/requires-scope.decorator';
import { import {
DynamicModule, DynamicModule,
ForwardReference, ForwardReference,
RequestMethod, RequestMethod,
Type Type,
VERSION_NEUTRAL
} from '@nestjs/common'; } from '@nestjs/common';
import { import {
GUARDS_METADATA, GUARDS_METADATA,
INTERCEPTORS_METADATA,
METHOD_METADATA, METHOD_METADATA,
MODULE_METADATA, MODULE_METADATA,
PATH_METADATA PATH_METADATA,
VERSION_METADATA
} from '@nestjs/common/constants'; } from '@nestjs/common/constants';
import { VersionValue } from '@nestjs/common/interfaces';
import { MetadataScanner } from '@nestjs/core'; import { MetadataScanner } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { isUndefined, omitBy } from 'lodash-es';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
@ -26,6 +35,13 @@ jest.mock('ai', () => {
type ModuleDefinition = type ModuleDefinition =
DynamicModule | ForwardReference<() => Type> | Promise<DynamicModule> | Type; DynamicModule | ForwardReference<() => Type> | Promise<DynamicModule> | Type;
/**
* The strategies of the authentication which the routes apply via
* AuthGuard(). The class of such a guard has a random name, hence the
* snapshot of the routes names it by its strategy
*/
const AUTHENTICATION_STRATEGIES = ['api-key', 'google', 'jwt', 'oidc'];
/** /**
* The routes of the controllers which answer a request without the * The routes of the controllers which answer a request without the
* authentication of a user or of an API key, sorted by the request method and * authentication of a user or of an API key, sorted by the request method and
@ -98,6 +114,57 @@ async function getControllers(
return [...new Set(controllers)]; return [...new Set(controllers)];
} }
/**
* Gives the guards or the interceptors which the decorators of the controller
* and of the route handler set, in the order in which they run
*/
function getEnhancers(key: string, controller: Type, routeHandler: object) {
return [controller, routeHandler].flatMap((target) => {
return (Reflect.getMetadata(key, target) ?? []) as Type[];
});
}
/**
* Gives the metadata of the route handler or, if it has none, of the
* controller, as Reflector.getAllAndOverride() does
*/
function getMetadataOfRoute<T>(
key: string,
controller: Type,
routeHandler: object
) {
return (Reflect.getMetadata(key, routeHandler) ??
Reflect.getMetadata(key, controller)) as T | undefined;
}
/**
* Gives the name of a guard or of an interceptor. The function mixin()
* replaces the name of a class by a random one, hence the name is read from
* the declaration of the class. A guard which extends AuthGuard() is named
* with its strategy
*/
function getName(enhancer: Type) {
const authenticationStrategy = AUTHENTICATION_STRATEGIES.find((strategy) => {
return AuthGuard(strategy) === enhancer;
});
if (authenticationStrategy) {
return `AuthGuard('${authenticationStrategy}')`;
}
const name = /^class\s+([\w$]+)/.exec(enhancer.toString())?.[1];
if (!name || name === 'MixinAuthGuard') {
throw new Error('The name of a guard or of an interceptor is unknown');
}
const parentStrategy = AUTHENTICATION_STRATEGIES.find((strategy) => {
return Object.getPrototypeOf(enhancer) === AuthGuard(strategy);
});
return parentStrategy ? `${name}('${parentStrategy}')` : name;
}
/** /**
* Gives the paths which the decorator of a controller or of a route sets * Gives the paths which the decorator of a controller or of a route sets
*/ */
@ -108,14 +175,10 @@ function getPaths(target: object) {
} }
/** /**
* Gives the routes of the controller which apply neither AuthGuard('api-key') * Gives the routes of the controller, each as the request method and the path
* nor AuthGuard('jwt'), each as the request method and the path * with its route handler
*/ */
function getRoutesWithoutAuthentication(controller: Type) { function getRoutes(controller: Type) {
const authenticationGuards: unknown[] = [
AuthGuard('api-key'),
AuthGuard('jwt')
];
const routeHandlersByName = controller.prototype as Record<string, object>; const routeHandlersByName = controller.prototype as Record<string, object>;
return new MetadataScanner() return new MetadataScanner()
@ -131,19 +194,6 @@ function getRoutesWithoutAuthentication(controller: Type) {
return []; 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(controller).flatMap((controllerPath) => {
return getPaths(routeHandler).map((routePath) => { return getPaths(routeHandler).map((routePath) => {
const path = `${controllerPath}/${routePath}` const path = `${controllerPath}/${routePath}`
@ -151,12 +201,110 @@ function getRoutesWithoutAuthentication(controller: Type) {
.filter(Boolean) .filter(Boolean)
.join('/'); .join('/');
return `${RequestMethod[requestMethod]} /${path}`; return {
routeHandler,
route: `${RequestMethod[requestMethod]} /${path}`
};
}); });
}); });
}); });
} }
/**
* 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')
];
return getRoutes(controller)
.filter(({ routeHandler }) => {
const hasAuthentication = getEnhancers(
GUARDS_METADATA,
controller,
routeHandler
).some((guard) => {
return authenticationGuards.includes(guard);
});
return !hasAuthentication;
})
.map(({ route }) => {
return route;
});
}
/**
* Gives the routes of the controller, each as the request method, the path
* and the version with the guards and the interceptors in the order in which
* they run, the permission, the scopes and whether the route stays available
* while an impersonation is active
*/
function getRoutesWithSecurity(controller: Type) {
return getRoutes(controller).map(({ route, routeHandler }) => {
const security = omitBy(
{
allowDuringImpersonation: getMetadataOfRoute<boolean>(
ALLOW_DURING_IMPERSONATION_KEY,
controller,
routeHandler
),
guards: getEnhancers(GUARDS_METADATA, controller, routeHandler).map(
(guard) => {
return getName(guard);
}
),
interceptors: getEnhancers(
INTERCEPTORS_METADATA,
controller,
routeHandler
).map((interceptor) => {
return getName(interceptor);
}),
// HasPermissionGuard reads the permission of the route handler only
permission: Reflect.getMetadata(HAS_PERMISSION_KEY, routeHandler) as
string | undefined,
scopes: getMetadataOfRoute<string[]>(
REQUIRES_SCOPE_KEY,
controller,
routeHandler
)
},
isUndefined
);
return [
`${route}${getVersionSuffix(controller, routeHandler)}`,
security
] as const;
});
}
/**
* Gives the version of the route as a suffix of its key, which is empty for
* the default version
*/
function getVersionSuffix(controller: Type, routeHandler: object) {
const version = getMetadataOfRoute<VersionValue>(
VERSION_METADATA,
controller,
routeHandler
);
if (version === undefined) {
return '';
}
const versions = [version].flat().map((value) => {
return value === VERSION_NEUTRAL ? 'neutral' : value;
});
return ` (version ${versions.join(', ')})`;
}
describe('AppModule', () => { describe('AppModule', () => {
it('should require the authentication for each route which is not public', async () => { it('should require the authentication for each route which is not public', async () => {
const controllers = await getControllers(AppModule); const controllers = await getControllers(AppModule);
@ -169,4 +317,22 @@ describe('AppModule', () => {
expect(routesWithoutAuthentication).toEqual(PUBLIC_ROUTES); expect(routesWithoutAuthentication).toEqual(PUBLIC_ROUTES);
}); });
// An intended change of a route is confirmed by an update of the snapshot
// (--updateSnapshot), hence each change shows in the review
it('should keep the security of each route', async () => {
const controllers = await getControllers(AppModule);
const routesWithSecurity = controllers.flatMap((controller) => {
return getRoutesWithSecurity(controller);
});
const routes = routesWithSecurity.map(([route]) => {
return route;
});
// The snapshot is an object, which would hide a route declared twice
expect(new Set(routes).size).toBe(routes.length);
expect(Object.fromEntries(routesWithSecurity)).toMatchSnapshot();
});
}); });

Loading…
Cancel
Save