Merge feat/keycloak into add_filters with manual conflict resolution.
Preserve Keycloak auth/RBAC contracts while retaining filter behavior and generator consistency for future regenerations. Made-with: Cursor
This commit is contained in:
@@ -1 +1,7 @@
|
||||
PORT=3000
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/toir"
|
||||
CORS_ALLOWED_ORIGINS="http://localhost:5173,https://toir-frontend.greact.ru"
|
||||
KEYCLOAK_ISSUER_URL="https://sso.greact.ru/realms/toir"
|
||||
KEYCLOAK_AUDIENCE="toir-backend"
|
||||
# Optional: if omitted, backend uses OIDC discovery and then falls back to issuer + /protocol/openid-connect/certs
|
||||
# KEYCLOAK_JWKS_URL="https://sso.greact.ru/realms/toir/protocol/openid-connect/certs"
|
||||
|
||||
10
server/package-lock.json
generated
10
server/package-lock.json
generated
@@ -15,6 +15,7 @@
|
||||
"@nestjs/core": "^10.0.0",
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"jose": "^6.2.2",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
@@ -6675,6 +6676,15 @@
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
|
||||
"integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"generate:from-dsl": "node ../generation/generate.mjs --apply --dsl examples/TOiR.domain.dsl",
|
||||
"generate:from-dsl": "node ../generation/generate.mjs --apply --dsl domain/TOiR.domain.dsl",
|
||||
"postinstall": "prisma generate"
|
||||
},
|
||||
"prisma": {
|
||||
@@ -30,6 +30,7 @@
|
||||
"@nestjs/core": "^10.0.0",
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"jose": "^6.2.2",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { validateEnvironment } from './config/env.validation';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { EquipmentTypeModule } from './modules/equipment-type/equipment-type.module';
|
||||
@@ -7,7 +9,11 @@ import { EquipmentModule } from './modules/equipment/equipment.module';
|
||||
import { RepairOrderModule } from './modules/repair-order/repair-order.module';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule.forRoot({ isGlobal: true }),
|
||||
imports: [ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
validate: validateEnvironment,
|
||||
}),
|
||||
AuthModule,
|
||||
PrismaModule,
|
||||
HealthModule,
|
||||
EquipmentTypeModule,
|
||||
|
||||
3
server/src/auth/auth.constants.ts
Normal file
3
server/src/auth/auth.constants.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const ROLES_KEY = 'roles';
|
||||
|
||||
22
server/src/auth/auth.module.ts
Normal file
22
server/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { RolesGuard } from './guards/roles.guard';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
AuthService,
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: JwtAuthGuard,
|
||||
},
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: RolesGuard,
|
||||
},
|
||||
],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
129
server/src/auth/auth.service.ts
Normal file
129
server/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||
import { RuntimeEnvironment } from '../config/env.validation';
|
||||
import {
|
||||
AuthenticatedUser,
|
||||
KeycloakJwtPayload,
|
||||
} from './interfaces/authenticated-user.interface';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
private readonly issuerUrl: string;
|
||||
private readonly audience: string;
|
||||
private readonly explicitJwksUrl?: string;
|
||||
|
||||
private jwksResolverPromise: Promise<ReturnType<typeof createRemoteJWKSet>> | null =
|
||||
null;
|
||||
private jwksResolver: ReturnType<typeof createRemoteJWKSet> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService<RuntimeEnvironment, true>,
|
||||
) {
|
||||
this.issuerUrl = this.configService.getOrThrow('KEYCLOAK_ISSUER_URL');
|
||||
this.audience = this.configService.getOrThrow('KEYCLOAK_AUDIENCE');
|
||||
this.explicitJwksUrl = this.configService.get('KEYCLOAK_JWKS_URL');
|
||||
}
|
||||
|
||||
async verifyAccessToken(token: string): Promise<AuthenticatedUser> {
|
||||
try {
|
||||
const jwksResolver = await this.getJwksResolver();
|
||||
|
||||
const { payload } = await jwtVerify(token, jwksResolver, {
|
||||
issuer: this.issuerUrl,
|
||||
audience: this.audience,
|
||||
});
|
||||
|
||||
return this.mapPayloadToUser(payload as KeycloakJwtPayload);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`JWT verification failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
throw new UnauthorizedException('Invalid or expired access token');
|
||||
}
|
||||
}
|
||||
|
||||
private mapPayloadToUser(payload: KeycloakJwtPayload): AuthenticatedUser {
|
||||
if (!payload.sub) {
|
||||
throw new UnauthorizedException('Token subject is missing');
|
||||
}
|
||||
|
||||
const roles = Array.isArray(payload.realm_access?.roles)
|
||||
? payload.realm_access.roles.filter(
|
||||
(role): role is string => typeof role === 'string',
|
||||
)
|
||||
: [];
|
||||
|
||||
return {
|
||||
sub: payload.sub,
|
||||
username: payload.preferred_username,
|
||||
name: payload.name,
|
||||
email: payload.email,
|
||||
roles,
|
||||
claims: payload,
|
||||
};
|
||||
}
|
||||
|
||||
private async getJwksResolver() {
|
||||
if (this.jwksResolver) {
|
||||
return this.jwksResolver;
|
||||
}
|
||||
|
||||
if (!this.jwksResolverPromise) {
|
||||
this.jwksResolverPromise = this.createJwksResolver()
|
||||
.then((resolver) => {
|
||||
this.jwksResolver = resolver;
|
||||
return resolver;
|
||||
})
|
||||
.finally(() => {
|
||||
this.jwksResolverPromise = null;
|
||||
});
|
||||
}
|
||||
|
||||
return this.jwksResolverPromise;
|
||||
}
|
||||
|
||||
private async createJwksResolver() {
|
||||
const jwksUrl = await this.resolveJwksUrl();
|
||||
this.logger.log(`Using JWKS URL: ${jwksUrl}`);
|
||||
return createRemoteJWKSet(new URL(jwksUrl));
|
||||
}
|
||||
|
||||
private async resolveJwksUrl(): Promise<string> {
|
||||
if (this.explicitJwksUrl) {
|
||||
return this.explicitJwksUrl;
|
||||
}
|
||||
|
||||
const issuer = this.issuerUrl.replace(/\/+$/, '');
|
||||
const discoveryUrl = `${issuer}/.well-known/openid-configuration`;
|
||||
|
||||
try {
|
||||
const discoveryResponse = await fetch(discoveryUrl, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (discoveryResponse.ok) {
|
||||
const discoveryDocument = (await discoveryResponse.json()) as {
|
||||
jwks_uri?: string;
|
||||
};
|
||||
|
||||
if (
|
||||
typeof discoveryDocument.jwks_uri === 'string' &&
|
||||
discoveryDocument.jwks_uri.trim().length > 0
|
||||
) {
|
||||
return discoveryDocument.jwks_uri;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`OIDC discovery failed at ${discoveryUrl}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
|
||||
return `${issuer}/protocol/openid-connect/certs`;
|
||||
}
|
||||
}
|
||||
|
||||
5
server/src/auth/decorators/public.decorator.ts
Normal file
5
server/src/auth/decorators/public.decorator.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { IS_PUBLIC_KEY } from '../auth.constants';
|
||||
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
|
||||
6
server/src/auth/decorators/roles.decorator.ts
Normal file
6
server/src/auth/decorators/roles.decorator.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { ROLES_KEY } from '../auth.constants';
|
||||
import { RealmRole } from '../roles/realm-role.enum';
|
||||
|
||||
export const Roles = (...roles: RealmRole[]) => SetMetadata(ROLES_KEY, roles);
|
||||
|
||||
54
server/src/auth/guards/jwt-auth.guard.ts
Normal file
54
server/src/auth/guards/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Request } from 'express';
|
||||
import { IS_PUBLIC_KEY } from '../auth.constants';
|
||||
import { AuthService } from '../auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly authService: AuthService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const token = this.extractBearerToken(request);
|
||||
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('Missing bearer token');
|
||||
}
|
||||
|
||||
request.user = await this.authService.verifyAccessToken(token);
|
||||
return true;
|
||||
}
|
||||
|
||||
private extractBearerToken(request: Request): string | null {
|
||||
const authorization = request.headers.authorization;
|
||||
if (!authorization) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [scheme, token] = authorization.split(' ');
|
||||
if (scheme?.toLowerCase() !== 'bearer' || !token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
49
server/src/auth/guards/roles.guard.ts
Normal file
49
server/src/auth/guards/roles.guard.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Request } from 'express';
|
||||
import { IS_PUBLIC_KEY, ROLES_KEY } from '../auth.constants';
|
||||
import { RealmRole } from '../roles/realm-role.enum';
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const requiredRoles =
|
||||
this.reflector.getAllAndOverride<RealmRole[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]) ?? [];
|
||||
|
||||
if (requiredRoles.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const userRoles = request.user?.roles ?? [];
|
||||
const hasRequiredRole = requiredRoles.some((role) =>
|
||||
userRoles.includes(role),
|
||||
);
|
||||
|
||||
if (!hasRequiredRole) {
|
||||
throw new ForbiddenException('Access denied: insufficient role');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
20
server/src/auth/interfaces/authenticated-user.interface.ts
Normal file
20
server/src/auth/interfaces/authenticated-user.interface.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { JWTPayload } from 'jose';
|
||||
|
||||
export interface KeycloakJwtPayload extends JWTPayload {
|
||||
preferred_username?: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
realm_access?: {
|
||||
roles?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface AuthenticatedUser {
|
||||
sub: string;
|
||||
username?: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
roles: string[];
|
||||
claims: KeycloakJwtPayload;
|
||||
}
|
||||
|
||||
12
server/src/auth/interfaces/express-request.interface.ts
Normal file
12
server/src/auth/interfaces/express-request.interface.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { AuthenticatedUser } from './authenticated-user.interface';
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: AuthenticatedUser;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
6
server/src/auth/roles/realm-role.enum.ts
Normal file
6
server/src/auth/roles/realm-role.enum.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export enum RealmRole {
|
||||
Admin = 'admin',
|
||||
Editor = 'editor',
|
||||
Viewer = 'viewer',
|
||||
}
|
||||
|
||||
58
server/src/config/env.validation.ts
Normal file
58
server/src/config/env.validation.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
export interface RuntimeEnvironment {
|
||||
PORT: number;
|
||||
DATABASE_URL: string;
|
||||
CORS_ALLOWED_ORIGINS: string;
|
||||
KEYCLOAK_ISSUER_URL: string;
|
||||
KEYCLOAK_AUDIENCE: string;
|
||||
KEYCLOAK_JWKS_URL?: string;
|
||||
}
|
||||
|
||||
function getRequiredString(
|
||||
config: Record<string, unknown>,
|
||||
key: keyof RuntimeEnvironment,
|
||||
): string {
|
||||
const value = config[key];
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw new Error(`Missing required environment variable: ${key}`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function getOptionalString(
|
||||
config: Record<string, unknown>,
|
||||
key: keyof RuntimeEnvironment,
|
||||
): string | undefined {
|
||||
const value = config[key];
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function parsePort(value: unknown): number {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return 3000;
|
||||
}
|
||||
|
||||
const port = Number(value);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error('Environment variable PORT must be an integer between 1 and 65535');
|
||||
}
|
||||
|
||||
return port;
|
||||
}
|
||||
|
||||
export function validateEnvironment(
|
||||
config: Record<string, unknown>,
|
||||
): RuntimeEnvironment {
|
||||
return {
|
||||
PORT: parsePort(config.PORT),
|
||||
DATABASE_URL: getRequiredString(config, 'DATABASE_URL'),
|
||||
CORS_ALLOWED_ORIGINS: getRequiredString(config, 'CORS_ALLOWED_ORIGINS'),
|
||||
KEYCLOAK_ISSUER_URL: getRequiredString(config, 'KEYCLOAK_ISSUER_URL'),
|
||||
KEYCLOAK_AUDIENCE: getRequiredString(config, 'KEYCLOAK_AUDIENCE'),
|
||||
KEYCLOAK_JWKS_URL: getOptionalString(config, 'KEYCLOAK_JWKS_URL'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { Public } from '../auth/decorators/public.decorator';
|
||||
|
||||
@Public()
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
|
||||
@@ -1,12 +1,35 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AppModule } from './app.module';
|
||||
import { RuntimeEnvironment } from './config/env.validation';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const configService = app.get<ConfigService<RuntimeEnvironment, true>>(
|
||||
ConfigService,
|
||||
);
|
||||
|
||||
const allowedOrigins = configService
|
||||
.getOrThrow('CORS_ALLOWED_ORIGINS')
|
||||
.split(',')
|
||||
.map((origin) => origin.trim())
|
||||
.filter((origin) => origin.length > 0);
|
||||
|
||||
app.enableCors({
|
||||
origin: true,
|
||||
origin: (origin, callback) => {
|
||||
if (!origin || allowedOrigins.includes(origin)) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
callback(new Error(`Origin ${origin} is not allowed by CORS`), false);
|
||||
},
|
||||
methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Authorization', 'Content-Type'],
|
||||
exposedHeaders: ['Content-Range'],
|
||||
credentials: false,
|
||||
});
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
|
||||
const port = configService.get('PORT', 3000);
|
||||
await app.listen(port);
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Controller, Get, Post, Patch, Delete, Param, Body, Query, Res } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { Roles } from '../../auth/decorators/roles.decorator';
|
||||
import { RealmRole } from '../../auth/roles/realm-role.enum';
|
||||
import { EquipmentTypeService } from './equipment-type.service';
|
||||
import { CreateEquipmentTypeDto } from './dto/create-equipment-type.dto';
|
||||
import { UpdateEquipmentTypeDto } from './dto/update-equipment-type.dto';
|
||||
@@ -8,6 +10,7 @@ import { UpdateEquipmentTypeDto } from './dto/update-equipment-type.dto';
|
||||
export class EquipmentTypeController {
|
||||
constructor(private readonly service: EquipmentTypeService) {}
|
||||
|
||||
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
|
||||
@Get()
|
||||
async findAll(@Query() query: any, @Res() res: Response) {
|
||||
const result = await this.service.findAll(query);
|
||||
@@ -16,21 +19,25 @@ export class EquipmentTypeController {
|
||||
return res.json(result.data);
|
||||
}
|
||||
|
||||
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
|
||||
@Get(':code')
|
||||
findOne(@Param('code') id: string) {
|
||||
return this.service.findOne(id);
|
||||
}
|
||||
|
||||
@Roles(RealmRole.Editor, RealmRole.Admin)
|
||||
@Post()
|
||||
create(@Body() dto: CreateEquipmentTypeDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Roles(RealmRole.Editor, RealmRole.Admin)
|
||||
@Patch(':code')
|
||||
update(@Param('code') id: string, @Body() dto: UpdateEquipmentTypeDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Roles(RealmRole.Admin)
|
||||
@Delete(':code')
|
||||
remove(@Param('code') id: string) {
|
||||
return this.service.remove(id);
|
||||
|
||||
@@ -22,6 +22,7 @@ export class EquipmentTypeService {
|
||||
const take = end - start;
|
||||
const skip = start;
|
||||
const sortField = query._sort || 'code';
|
||||
const prismaSortField = sortField === 'id' ? 'code' : sortField;
|
||||
const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc';
|
||||
|
||||
const where: any = {};
|
||||
@@ -50,11 +51,11 @@ export class EquipmentTypeService {
|
||||
}
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.equipmentType.findMany({ where, skip, take, orderBy: { [sortField]: sortOrder } }),
|
||||
this.prisma.equipmentType.findMany({ where, skip, take, orderBy: { [prismaSortField]: sortOrder } }),
|
||||
this.prisma.equipmentType.count({ where }),
|
||||
]);
|
||||
|
||||
const mapped = data.map((r: any) => ({ id: r.code, ...serializeRecord(r) }));
|
||||
const mapped = data.map((item: any) => ({ id: item.code, ...serializeRecord(item) }));
|
||||
return { data: mapped, total };
|
||||
}
|
||||
|
||||
@@ -73,9 +74,8 @@ export class EquipmentTypeService {
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateEquipmentTypeDto) {
|
||||
const data: any = { ...(dto as any) };
|
||||
delete data.id;
|
||||
delete data.code;
|
||||
const { id: _pk, code, ...rest } = (dto as any);
|
||||
const data: any = { ...rest };
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Controller, Get, Post, Patch, Delete, Param, Body, Query, Res } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { Roles } from '../../auth/decorators/roles.decorator';
|
||||
import { RealmRole } from '../../auth/roles/realm-role.enum';
|
||||
import { EquipmentService } from './equipment.service';
|
||||
import { CreateEquipmentDto } from './dto/create-equipment.dto';
|
||||
import { UpdateEquipmentDto } from './dto/update-equipment.dto';
|
||||
@@ -8,6 +10,7 @@ import { UpdateEquipmentDto } from './dto/update-equipment.dto';
|
||||
export class EquipmentController {
|
||||
constructor(private readonly service: EquipmentService) {}
|
||||
|
||||
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
|
||||
@Get()
|
||||
async findAll(@Query() query: any, @Res() res: Response) {
|
||||
const result = await this.service.findAll(query);
|
||||
@@ -16,21 +19,25 @@ export class EquipmentController {
|
||||
return res.json(result.data);
|
||||
}
|
||||
|
||||
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.service.findOne(id);
|
||||
}
|
||||
|
||||
@Roles(RealmRole.Editor, RealmRole.Admin)
|
||||
@Post()
|
||||
create(@Body() dto: CreateEquipmentDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Roles(RealmRole.Editor, RealmRole.Admin)
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateEquipmentDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Roles(RealmRole.Admin)
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.service.remove(id);
|
||||
|
||||
@@ -24,6 +24,7 @@ export class EquipmentService {
|
||||
const take = end - start;
|
||||
const skip = start;
|
||||
const sortField = query._sort || 'inventoryNumber';
|
||||
const prismaSortField = sortField === 'id' ? 'id' : sortField;
|
||||
const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc';
|
||||
|
||||
const where: any = {};
|
||||
@@ -57,7 +58,7 @@ export class EquipmentService {
|
||||
}
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.equipment.findMany({ where, skip, take, orderBy: { [sortField]: sortOrder } }),
|
||||
this.prisma.equipment.findMany({ where, skip, take, orderBy: { [prismaSortField]: sortOrder } }),
|
||||
this.prisma.equipment.count({ where }),
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Controller, Get, Post, Patch, Delete, Param, Body, Query, Res } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { Roles } from '../../auth/decorators/roles.decorator';
|
||||
import { RealmRole } from '../../auth/roles/realm-role.enum';
|
||||
import { RepairOrderService } from './repair-order.service';
|
||||
import { CreateRepairOrderDto } from './dto/create-repair-order.dto';
|
||||
import { UpdateRepairOrderDto } from './dto/update-repair-order.dto';
|
||||
@@ -8,6 +10,7 @@ import { UpdateRepairOrderDto } from './dto/update-repair-order.dto';
|
||||
export class RepairOrderController {
|
||||
constructor(private readonly service: RepairOrderService) {}
|
||||
|
||||
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
|
||||
@Get()
|
||||
async findAll(@Query() query: any, @Res() res: Response) {
|
||||
const result = await this.service.findAll(query);
|
||||
@@ -16,21 +19,25 @@ export class RepairOrderController {
|
||||
return res.json(result.data);
|
||||
}
|
||||
|
||||
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.service.findOne(id);
|
||||
}
|
||||
|
||||
@Roles(RealmRole.Editor, RealmRole.Admin)
|
||||
@Post()
|
||||
create(@Body() dto: CreateRepairOrderDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Roles(RealmRole.Editor, RealmRole.Admin)
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateRepairOrderDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Roles(RealmRole.Admin)
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.service.remove(id);
|
||||
|
||||
@@ -24,6 +24,7 @@ export class RepairOrderService {
|
||||
const take = end - start;
|
||||
const skip = start;
|
||||
const sortField = query._sort || 'number';
|
||||
const prismaSortField = sortField === 'id' ? 'id' : sortField;
|
||||
const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc';
|
||||
|
||||
const where: any = {};
|
||||
@@ -55,7 +56,7 @@ export class RepairOrderService {
|
||||
}
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.repairOrder.findMany({ where, skip, take, orderBy: { [sortField]: sortOrder } }),
|
||||
this.prisma.repairOrder.findMany({ where, skip, take, orderBy: { [prismaSortField]: sortOrder } }),
|
||||
this.prisma.repairOrder.count({ where }),
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,24 +1,83 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import * as request from 'supertest';
|
||||
import { AuthService } from '../src/auth/auth.service';
|
||||
import { AuthenticatedUser } from '../src/auth/interfaces/authenticated-user.interface';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import { AppModule } from './../src/app.module';
|
||||
|
||||
describe('AppController (e2e)', () => {
|
||||
describe('Auth and Health (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
let authServiceMock: {
|
||||
verifyAccessToken: jest.Mock<Promise<AuthenticatedUser>, [string]>;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.PORT = '3000';
|
||||
process.env.DATABASE_URL =
|
||||
process.env.DATABASE_URL ??
|
||||
'postgresql://postgres:postgres@localhost:5432/toir';
|
||||
process.env.CORS_ALLOWED_ORIGINS =
|
||||
process.env.CORS_ALLOWED_ORIGINS ??
|
||||
'http://localhost:5173,https://toir-frontend.greact.ru';
|
||||
process.env.KEYCLOAK_ISSUER_URL =
|
||||
process.env.KEYCLOAK_ISSUER_URL ?? 'https://sso.greact.ru/realms/toir';
|
||||
process.env.KEYCLOAK_AUDIENCE =
|
||||
process.env.KEYCLOAK_AUDIENCE ?? 'toir-backend';
|
||||
|
||||
authServiceMock = {
|
||||
verifyAccessToken: jest.fn<Promise<AuthenticatedUser>, [string]>(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
})
|
||||
.overrideProvider(AuthService)
|
||||
.useValue(authServiceMock)
|
||||
.overrideProvider(PrismaService)
|
||||
.useValue({})
|
||||
.compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it('/ (GET)', () => {
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
authServiceMock.verifyAccessToken.mockReset();
|
||||
});
|
||||
|
||||
it('/health (GET) is public', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/')
|
||||
.get('/health')
|
||||
.expect(200)
|
||||
.expect('Hello World!');
|
||||
.expect({ status: 'ok' });
|
||||
});
|
||||
|
||||
it('/equipment (GET) requires authentication', () => {
|
||||
return request(app.getHttpServer()).get('/equipment').expect(401);
|
||||
});
|
||||
|
||||
it('/equipment (POST) returns 403 for authenticated viewer role', async () => {
|
||||
authServiceMock.verifyAccessToken.mockResolvedValue({
|
||||
sub: 'viewer-user',
|
||||
username: 'viewer-user',
|
||||
roles: ['viewer'],
|
||||
claims: {
|
||||
sub: 'viewer-user',
|
||||
realm_access: {
|
||||
roles: ['viewer'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/equipment')
|
||||
.set('Authorization', 'Bearer viewer-token')
|
||||
.send({})
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,5 +5,8 @@
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"moduleNameMapper": {
|
||||
"^jose$": "<rootDir>/mocks/jose.ts"
|
||||
}
|
||||
}
|
||||
|
||||
8
server/test/mocks/jose.ts
Normal file
8
server/test/mocks/jose.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const createRemoteJWKSet = () => {
|
||||
return async () => ({}) as never;
|
||||
};
|
||||
|
||||
export const jwtVerify = async () => {
|
||||
return { payload: {} };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user