PermissionsGuard erlaubte bisher jede Route ohne @RequirePermission automatisch fuer jeden eingeloggten Nutzer (fail-open) und verliess sich allein auf test/authorization-coverage.e2e-spec.ts, um eine vergessene Decorator-Angabe vor dem Deploy zu erwischen - der Guard selbst hatte kein Default-Deny. Neuer Decorator @AnyAuthenticatedUser() macht das bisher stillschweigend erlaubte Verhalten explizit fuer die 12 Routen, die es tatsaechlich brauchen (ProfileController komplett, NotificationsController komplett, AuthController.me - alle bereits vorher auf der Allowlist des e2e-Tests). PermissionsGuard verweigert jetzt jede Route, die weder @RequirePermission noch @AnyAuthenticatedUser traegt - eine vergessene Decorator-Angabe schlaegt jetzt zur Laufzeit fehl (403), nicht mehr nur im Test. Der e2e-Test prueft jetzt denselben Decorator-Vertrag statt einer separat gepflegten Allowlist, kann also nicht mehr vom Guard-Verhalten abweichen. Neue Unit-Tests fuer PermissionsGuard decken beide Codepfade und das Fail-Closed-Verhalten ab. Co-Authored-By: Claude Sonnet 5 <[email protected]>
98 lines
3.6 KiB
TypeScript
98 lines
3.6 KiB
TypeScript
import { Test } from '@nestjs/testing';
|
|
import {
|
|
DiscoveryModule,
|
|
DiscoveryService,
|
|
MetadataScanner,
|
|
Reflector,
|
|
} from '@nestjs/core';
|
|
import { PATH_METADATA } from '@nestjs/common/constants';
|
|
import { AppModule } from '../src/app.module';
|
|
import { IS_PUBLIC_KEY } from '../src/auth/decorators/public.decorator';
|
|
import { REQUIRE_PERMISSION_KEY } from '../src/auth/decorators/require-permission.decorator';
|
|
import { ANY_AUTHENTICATED_USER_KEY } from '../src/auth/decorators/any-authenticated-user.decorator';
|
|
import { PERMISSION_CATALOG } from '../src/auth/permissions/permission-catalog';
|
|
|
|
/**
|
|
* Mirrors PermissionsGuard's own runtime contract exactly (see
|
|
* src/auth/guards/permissions.guard.ts): every non-public route must carry
|
|
* either @RequirePermission or @AnyAuthenticatedUser, or the guard denies it
|
|
* at runtime regardless of what this test says. This test exists to catch
|
|
* the mistake at build time - with a clear message pointing at the missing
|
|
* decorator - rather than as a live production 403 discovered by a user, but
|
|
* a route that fails it would also fail closed live, not open.
|
|
*/
|
|
describe('Authorization coverage (e2e)', () => {
|
|
it('every non-public route requires a permission or an explicit any-authenticated-user opt-in', async () => {
|
|
const moduleRef = await Test.createTestingModule({
|
|
imports: [AppModule, DiscoveryModule],
|
|
}).compile();
|
|
|
|
const discovery = moduleRef.get(DiscoveryService);
|
|
const reflector = moduleRef.get(Reflector);
|
|
const scanner = moduleRef.get(MetadataScanner);
|
|
const catalogKeys = new Set<string>(
|
|
PERMISSION_CATALOG.map((entry) => entry.key),
|
|
);
|
|
|
|
const uncoveredRoutes: string[] = [];
|
|
const invalidPermissionKeys: string[] = [];
|
|
|
|
for (const wrapper of discovery.getControllers()) {
|
|
const instance = wrapper.instance as object | undefined;
|
|
if (!instance) {
|
|
continue;
|
|
}
|
|
const constructor = instance.constructor;
|
|
const controllerName = constructor.name;
|
|
const prototype = Object.getPrototypeOf(instance) as object;
|
|
const isPublicClass =
|
|
reflector.get<boolean>(IS_PUBLIC_KEY, constructor) === true;
|
|
|
|
for (const methodName of scanner.getAllMethodNames(prototype)) {
|
|
const handler = (prototype as Record<string, () => unknown>)[
|
|
methodName
|
|
];
|
|
const isRouteHandler =
|
|
Reflect.getMetadata(PATH_METADATA, handler) !== undefined;
|
|
if (!isRouteHandler) {
|
|
continue;
|
|
}
|
|
const routeKey = `${controllerName}.${methodName}`;
|
|
const isPublicMethod =
|
|
reflector.get<boolean>(IS_PUBLIC_KEY, handler) === true;
|
|
if (isPublicClass || isPublicMethod) {
|
|
continue;
|
|
}
|
|
|
|
const permissions = reflector.getAllAndOverride<string[]>(
|
|
REQUIRE_PERMISSION_KEY,
|
|
[handler, constructor],
|
|
);
|
|
const hasPermissions =
|
|
Array.isArray(permissions) && permissions.length > 0;
|
|
const isAnyAuthenticatedUser =
|
|
reflector.getAllAndOverride<boolean>(ANY_AUTHENTICATED_USER_KEY, [
|
|
handler,
|
|
constructor,
|
|
]) === true;
|
|
|
|
if (!hasPermissions && !isAnyAuthenticatedUser) {
|
|
uncoveredRoutes.push(routeKey);
|
|
}
|
|
if (hasPermissions) {
|
|
for (const key of permissions) {
|
|
if (!catalogKeys.has(key)) {
|
|
invalidPermissionKeys.push(`${routeKey} -> ${key}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
expect(uncoveredRoutes).toEqual([]);
|
|
expect(invalidPermissionKeys).toEqual([]);
|
|
|
|
await moduleRef.close();
|
|
});
|
|
});
|