feature secu
This commit is contained in:
parent
8446f879b6
commit
c09b8be9ae
@ -4,7 +4,7 @@ echo "Waiting for PostgreSQL..."
|
|||||||
max_attempts=30
|
max_attempts=30
|
||||||
attempt=0
|
attempt=0
|
||||||
while [ $attempt -lt $max_attempts ]; do
|
while [ $attempt -lt $max_attempts ]; do
|
||||||
if node -e "const { Client } = require('pg'); const client = new Client({ host: process.env.DATABASE_HOST, port: process.env.DATABASE_PORT, user: process.env.DATABASE_USER, password: process.env.DATABASE_PASSWORD, database: process.env.DATABASE_NAME }); client.connect().then(() => { client.end(); process.exit(0); }).catch(() => process.exit(1));" 2>/dev/null; then
|
if node -e "const { Client } = require('pg'); const { databaseTlsOptions } = require('/app/dist/infrastructure/persistence/typeorm/database-tls'); const client = new Client({ host: process.env.DATABASE_HOST, port: process.env.DATABASE_PORT, user: process.env.DATABASE_USER, password: process.env.DATABASE_PASSWORD, database: process.env.DATABASE_NAME, ssl: databaseTlsOptions(process.env.DATABASE_SSL, process.env.DATABASE_SSL_CA, process.env.DATABASE_HOST) }); client.connect().then(() => { client.end(); process.exit(0); }).catch(() => process.exit(1));" 2>/dev/null; then
|
||||||
echo "PostgreSQL is ready"
|
echo "PostgreSQL is ready"
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
|
|||||||
@ -1,5 +1,12 @@
|
|||||||
const { DataSource } = require('typeorm');
|
const { DataSource } = require('typeorm');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const { existsSync } = require('fs');
|
||||||
|
const applicationRoot = existsSync(path.join(__dirname, 'dist'))
|
||||||
|
? __dirname
|
||||||
|
: path.resolve(__dirname, '../..');
|
||||||
|
const { databaseTlsOptions } = require(
|
||||||
|
path.join(applicationRoot, 'dist/infrastructure/persistence/typeorm/database-tls')
|
||||||
|
);
|
||||||
|
|
||||||
const AppDataSource = new DataSource({
|
const AppDataSource = new DataSource({
|
||||||
type: 'postgres',
|
type: 'postgres',
|
||||||
@ -8,8 +15,15 @@ const AppDataSource = new DataSource({
|
|||||||
username: process.env.DATABASE_USER,
|
username: process.env.DATABASE_USER,
|
||||||
password: process.env.DATABASE_PASSWORD,
|
password: process.env.DATABASE_PASSWORD,
|
||||||
database: process.env.DATABASE_NAME,
|
database: process.env.DATABASE_NAME,
|
||||||
entities: [path.join(__dirname, 'dist/**/*.orm-entity.js')],
|
ssl: databaseTlsOptions(
|
||||||
migrations: [path.join(__dirname, 'dist/infrastructure/persistence/typeorm/migrations/*.js')],
|
process.env.DATABASE_SSL,
|
||||||
|
process.env.DATABASE_SSL_CA,
|
||||||
|
process.env.DATABASE_HOST
|
||||||
|
),
|
||||||
|
entities: [path.join(applicationRoot, 'dist/**/*.orm-entity.js')],
|
||||||
|
migrations: [
|
||||||
|
path.join(applicationRoot, 'dist/infrastructure/persistence/typeorm/migrations/*.js'),
|
||||||
|
],
|
||||||
synchronize: false,
|
synchronize: false,
|
||||||
logging: true,
|
logging: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,6 +4,14 @@ const { Client } = require('pg');
|
|||||||
const { DataSource } = require('typeorm');
|
const { DataSource } = require('typeorm');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
|
const { existsSync } = require('fs');
|
||||||
|
// Docker copies this script to /app/startup.js; local copies stay in scripts/setup.
|
||||||
|
const applicationRoot = existsSync(path.join(__dirname, 'dist'))
|
||||||
|
? __dirname
|
||||||
|
: path.resolve(__dirname, '../..');
|
||||||
|
const { databaseTlsOptions } = require(
|
||||||
|
path.join(applicationRoot, 'dist/infrastructure/persistence/typeorm/database-tls')
|
||||||
|
);
|
||||||
|
|
||||||
async function waitForPostgres(maxAttempts = 30) {
|
async function waitForPostgres(maxAttempts = 30) {
|
||||||
console.log('⏳ Waiting for PostgreSQL to be ready...');
|
console.log('⏳ Waiting for PostgreSQL to be ready...');
|
||||||
@ -16,6 +24,11 @@ async function waitForPostgres(maxAttempts = 30) {
|
|||||||
user: process.env.DATABASE_USER,
|
user: process.env.DATABASE_USER,
|
||||||
password: process.env.DATABASE_PASSWORD,
|
password: process.env.DATABASE_PASSWORD,
|
||||||
database: process.env.DATABASE_NAME,
|
database: process.env.DATABASE_NAME,
|
||||||
|
ssl: databaseTlsOptions(
|
||||||
|
process.env.DATABASE_SSL,
|
||||||
|
process.env.DATABASE_SSL_CA,
|
||||||
|
process.env.DATABASE_HOST
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
await client.connect();
|
await client.connect();
|
||||||
@ -42,8 +55,15 @@ async function runMigrations() {
|
|||||||
username: process.env.DATABASE_USER,
|
username: process.env.DATABASE_USER,
|
||||||
password: process.env.DATABASE_PASSWORD,
|
password: process.env.DATABASE_PASSWORD,
|
||||||
database: process.env.DATABASE_NAME,
|
database: process.env.DATABASE_NAME,
|
||||||
entities: [path.join(__dirname, 'dist/**/*.orm-entity.js')],
|
ssl: databaseTlsOptions(
|
||||||
migrations: [path.join(__dirname, 'dist/infrastructure/persistence/typeorm/migrations/*.js')],
|
process.env.DATABASE_SSL,
|
||||||
|
process.env.DATABASE_SSL_CA,
|
||||||
|
process.env.DATABASE_HOST
|
||||||
|
),
|
||||||
|
entities: [path.join(applicationRoot, 'dist/**/*.orm-entity.js')],
|
||||||
|
migrations: [
|
||||||
|
path.join(applicationRoot, 'dist/infrastructure/persistence/typeorm/migrations/*.js'),
|
||||||
|
],
|
||||||
synchronize: false,
|
synchronize: false,
|
||||||
logging: true,
|
logging: true,
|
||||||
});
|
});
|
||||||
@ -78,6 +98,7 @@ function startApplication() {
|
|||||||
const app = spawn('node', ['dist/main'], {
|
const app = spawn('node', ['dist/main'], {
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
env: process.env,
|
env: process.env,
|
||||||
|
cwd: applicationRoot,
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on('exit', code => {
|
app.on('exit', code => {
|
||||||
@ -96,7 +117,11 @@ async function main() {
|
|||||||
startApplication();
|
startApplication();
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch(error => {
|
if (require.main === module) {
|
||||||
console.error('❌ Startup failed:', error);
|
main().catch(error => {
|
||||||
process.exit(1);
|
console.error('❌ Startup failed:', error);
|
||||||
});
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { waitForPostgres, runMigrations };
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import {
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as Joi from 'joi';
|
import * as Joi from 'joi';
|
||||||
import { UserPreferenceResolver } from './infrastructure/i18n/user-preference.resolver';
|
import { UserPreferenceResolver } from './infrastructure/i18n/user-preference.resolver';
|
||||||
|
import { databaseTlsOptions } from './infrastructure/persistence/typeorm/database-tls';
|
||||||
|
|
||||||
// Import feature modules
|
// Import feature modules
|
||||||
import { AuthModule } from './application/auth/auth.module';
|
import { AuthModule } from './application/auth/auth.module';
|
||||||
@ -61,6 +62,8 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
|
|||||||
DATABASE_USER: Joi.string().required(),
|
DATABASE_USER: Joi.string().required(),
|
||||||
DATABASE_PASSWORD: Joi.string().required(),
|
DATABASE_PASSWORD: Joi.string().required(),
|
||||||
DATABASE_NAME: Joi.string().required(),
|
DATABASE_NAME: Joi.string().required(),
|
||||||
|
DATABASE_SSL: Joi.boolean().default(false),
|
||||||
|
DATABASE_SSL_CA: Joi.string().optional(),
|
||||||
REDIS_HOST: Joi.string().required(),
|
REDIS_HOST: Joi.string().required(),
|
||||||
REDIS_PORT: Joi.number().default(6379),
|
REDIS_PORT: Joi.number().default(6379),
|
||||||
REDIS_PASSWORD: Joi.string().required(),
|
REDIS_PASSWORD: Joi.string().required(),
|
||||||
@ -170,6 +173,11 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
|
|||||||
username: configService.get('DATABASE_USER'),
|
username: configService.get('DATABASE_USER'),
|
||||||
password: configService.get('DATABASE_PASSWORD'),
|
password: configService.get('DATABASE_PASSWORD'),
|
||||||
database: configService.get('DATABASE_NAME'),
|
database: configService.get('DATABASE_NAME'),
|
||||||
|
ssl: databaseTlsOptions(
|
||||||
|
configService.get<boolean>('DATABASE_SSL'),
|
||||||
|
configService.get<string>('DATABASE_SSL_CA'),
|
||||||
|
configService.get<string>('DATABASE_HOST')
|
||||||
|
),
|
||||||
entities: [__dirname + '/**/*.orm-entity{.ts,.js}'],
|
entities: [__dirname + '/**/*.orm-entity{.ts,.js}'],
|
||||||
synchronize: false, // ✅ Force false - use migrations instead
|
synchronize: false, // ✅ Force false - use migrations instead
|
||||||
logging: configService.get('DATABASE_LOGGING', false),
|
logging: configService.get('DATABASE_LOGGING', false),
|
||||||
|
|||||||
69
apps/backend/src/application/auth/auth-session.spec.ts
Normal file
69
apps/backend/src/application/auth/auth-session.spec.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { AuthService, JwtPayload } from './auth.service';
|
||||||
|
import { User, UserRole } from '@domain/entities/user.entity';
|
||||||
|
import { UserRepository } from '@domain/ports/out/user.repository';
|
||||||
|
import { OrganizationRepository } from '@domain/ports/out/organization.repository';
|
||||||
|
import { EmailPort } from '@domain/ports/out/email.port';
|
||||||
|
import { CachePort } from '@domain/ports/out/cache.port';
|
||||||
|
import { PasswordResetTokenOrmEntity } from '@infrastructure/persistence/typeorm/entities/password-reset-token.orm-entity';
|
||||||
|
import { SubscriptionService } from '../services/subscription.service';
|
||||||
|
|
||||||
|
jest.mock('argon2', () => ({ verify: jest.fn().mockResolvedValue(true) }));
|
||||||
|
|
||||||
|
describe('password-bound sessions', () => {
|
||||||
|
let user: User;
|
||||||
|
let auth: AuthService;
|
||||||
|
let jwt: JwtService;
|
||||||
|
beforeEach(() => {
|
||||||
|
user = User.create({
|
||||||
|
id: 'user-1',
|
||||||
|
organizationId: 'org-1',
|
||||||
|
email: 'test@example.org',
|
||||||
|
firstName: 'Test',
|
||||||
|
lastName: 'User',
|
||||||
|
role: UserRole.ADMIN,
|
||||||
|
passwordHash: 'old-salted-hash',
|
||||||
|
});
|
||||||
|
jwt = new JwtService({ secret: 'test-only-session-secret' });
|
||||||
|
auth = new AuthService(
|
||||||
|
{
|
||||||
|
findById: jest.fn(async () => user),
|
||||||
|
findByEmail: jest.fn(async () => user),
|
||||||
|
} as unknown as UserRepository,
|
||||||
|
{} as OrganizationRepository,
|
||||||
|
{} as EmailPort,
|
||||||
|
{ get: jest.fn(async () => null) } as unknown as CachePort,
|
||||||
|
{} as Repository<PasswordResetTokenOrmEntity>,
|
||||||
|
jwt,
|
||||||
|
new ConfigService({ JWT_SECRET: 'test-only-session-secret' }),
|
||||||
|
{} as SubscriptionService
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects old access and refresh tokens after a password change, but accepts a new login', async () => {
|
||||||
|
const tokens = await auth.login(user.email, 'password');
|
||||||
|
const payload = jwt.verify<JwtPayload>(tokens.accessToken);
|
||||||
|
expect(await auth.validateUser(payload)).toBe(user);
|
||||||
|
expect(payload.credentialVersion).not.toContain(user.passwordHash);
|
||||||
|
user.updatePassword('new-salted-hash');
|
||||||
|
expect(await auth.validateUser(payload)).toBeNull();
|
||||||
|
await expect(auth.refreshAccessToken(tokens.refreshToken)).rejects.toThrow();
|
||||||
|
const fresh = await auth.login(user.email, 'new-password');
|
||||||
|
expect(await auth.validateUser(jwt.verify<JwtPayload>(fresh.accessToken))).toBe(user);
|
||||||
|
await expect(auth.refreshAccessToken(fresh.refreshToken)).resolves.toHaveProperty(
|
||||||
|
'accessToken'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves sessions after a profile change and rejects legacy or disabled sessions', async () => {
|
||||||
|
const tokens = await auth.login(user.email, 'password');
|
||||||
|
const payload = jwt.verify<JwtPayload>(tokens.accessToken);
|
||||||
|
user.updateFirstName('New name');
|
||||||
|
expect(await auth.validateUser(payload)).toBe(user);
|
||||||
|
expect(await auth.validateUser({ ...payload, credentialVersion: undefined })).toBeNull();
|
||||||
|
user.deactivate();
|
||||||
|
expect(await auth.validateUser(payload)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -35,6 +35,7 @@ export interface JwtPayload {
|
|||||||
plan?: string; // subscription plan (BRONZE, SILVER, GOLD, PLATINIUM)
|
plan?: string; // subscription plan (BRONZE, SILVER, GOLD, PLATINIUM)
|
||||||
planFeatures?: string[]; // plan feature flags
|
planFeatures?: string[]; // plan feature flags
|
||||||
type: 'access' | 'refresh';
|
type: 'access' | 'refresh';
|
||||||
|
credentialVersion?: string;
|
||||||
rememberMe?: boolean; // drives auth cookie persistence across refreshes
|
rememberMe?: boolean; // drives auth cookie persistence across refreshes
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -243,7 +244,7 @@ export class AuthService {
|
|||||||
throw new UnauthorizedException('Refresh token has been revoked');
|
throw new UnauthorizedException('Refresh token has been revoked');
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.userRepository.findById(payload.sub);
|
const user = await this.validateUser(payload);
|
||||||
|
|
||||||
if (!user || !user.isActive) {
|
if (!user || !user.isActive) {
|
||||||
throw new UnauthorizedException('User not found or inactive');
|
throw new UnauthorizedException('User not found or inactive');
|
||||||
@ -403,13 +404,22 @@ export class AuthService {
|
|||||||
async validateUser(payload: JwtPayload): Promise<User | null> {
|
async validateUser(payload: JwtPayload): Promise<User | null> {
|
||||||
const user = await this.userRepository.findById(payload.sub);
|
const user = await this.userRepository.findById(payload.sub);
|
||||||
|
|
||||||
if (!user || !user.isActive) {
|
if (!user || !user.isActive || payload.credentialVersion !== this.credentialVersion(user)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bind sessions to the current password hash without exposing the hash in JWTs.
|
||||||
|
// Tokens minted before this binding was introduced require a fresh login.
|
||||||
|
private credentialVersion(user: User): string {
|
||||||
|
return crypto
|
||||||
|
.createHmac('sha256', this.configService.getOrThrow<string>('JWT_SECRET'))
|
||||||
|
.update(JSON.stringify(['credential-version-v1', user.id, user.passwordHash]))
|
||||||
|
.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate access and refresh tokens
|
* Generate access and refresh tokens
|
||||||
*/
|
*/
|
||||||
@ -452,6 +462,7 @@ export class AuthService {
|
|||||||
plan,
|
plan,
|
||||||
planFeatures,
|
planFeatures,
|
||||||
type: 'access',
|
type: 'access',
|
||||||
|
credentialVersion: this.credentialVersion(user),
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshPayload: JwtPayload = {
|
const refreshPayload: JwtPayload = {
|
||||||
@ -462,6 +473,7 @@ export class AuthService {
|
|||||||
plan,
|
plan,
|
||||||
planFeatures,
|
planFeatures,
|
||||||
type: 'refresh',
|
type: 'refresh',
|
||||||
|
credentialVersion: this.credentialVersion(user),
|
||||||
rememberMe,
|
rememberMe,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,7 @@ export interface JwtPayload {
|
|||||||
role: string;
|
role: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
type: 'access' | 'refresh';
|
type: 'access' | 'refresh';
|
||||||
|
credentialVersion?: string;
|
||||||
iat?: number; // issued at
|
iat?: number; // issued at
|
||||||
exp?: number; // expiration
|
exp?: number; // expiration
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,6 +31,8 @@ import {
|
|||||||
ApiParam,
|
ApiParam,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../guards/roles.guard';
|
||||||
|
import { Roles } from '../decorators/roles.decorator';
|
||||||
import { Public } from '../decorators/public.decorator';
|
import { Public } from '../decorators/public.decorator';
|
||||||
import { CsvBookingService } from '../services/csv-booking.service';
|
import { CsvBookingService } from '../services/csv-booking.service';
|
||||||
import { SubscriptionService } from '../services/subscription.service';
|
import { SubscriptionService } from '../services/subscription.service';
|
||||||
@ -84,8 +86,20 @@ export class CsvBookingsController {
|
|||||||
* POST /api/v1/csv-bookings
|
* POST /api/v1/csv-bookings
|
||||||
*/
|
*/
|
||||||
@Post()
|
@Post()
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('ADMIN', 'MANAGER', 'USER')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseInterceptors(FilesInterceptor('documents', 10))
|
@UseInterceptors(
|
||||||
|
FilesInterceptor('documents', 10, {
|
||||||
|
limits: {
|
||||||
|
fileSize: 10 * 1024 * 1024,
|
||||||
|
files: 10,
|
||||||
|
fields: 40,
|
||||||
|
parts: 50,
|
||||||
|
fieldSize: 64 * 1024,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
@ApiConsumes('multipart/form-data')
|
@ApiConsumes('multipart/form-data')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Create a new CSV booking request',
|
summary: 'Create a new CSV booking request',
|
||||||
@ -144,13 +158,6 @@ export class CsvBookingsController {
|
|||||||
@Request() req: any
|
@Request() req: any
|
||||||
): Promise<CsvBookingResponseDto> {
|
): Promise<CsvBookingResponseDto> {
|
||||||
// Debug: Log request details
|
// Debug: Log request details
|
||||||
console.log('=== CSV Booking Request Debug ===');
|
|
||||||
console.log('req.user:', req.user);
|
|
||||||
console.log('req.body:', req.body);
|
|
||||||
console.log('dto:', dto);
|
|
||||||
console.log('files:', files?.length);
|
|
||||||
console.log('================================');
|
|
||||||
|
|
||||||
if (!files || files.length === 0) {
|
if (!files || files.length === 0) {
|
||||||
throw new BadRequestException('At least one document is required');
|
throw new BadRequestException('At least one document is required');
|
||||||
}
|
}
|
||||||
@ -288,6 +295,8 @@ export class CsvBookingsController {
|
|||||||
* GET /api/v1/csv-bookings/stats/organization
|
* GET /api/v1/csv-bookings/stats/organization
|
||||||
*/
|
*/
|
||||||
@Get('stats/organization')
|
@Get('stats/organization')
|
||||||
|
@UseGuards(RolesGuard)
|
||||||
|
@Roles('ADMIN', 'MANAGER')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
@ -311,6 +320,8 @@ export class CsvBookingsController {
|
|||||||
* GET /api/v1/csv-bookings/organization/all
|
* GET /api/v1/csv-bookings/organization/all
|
||||||
*/
|
*/
|
||||||
@Get('organization/all')
|
@Get('organization/all')
|
||||||
|
@UseGuards(RolesGuard)
|
||||||
|
@Roles('ADMIN', 'MANAGER')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
@ -416,6 +427,8 @@ export class CsvBookingsController {
|
|||||||
* POST /api/v1/csv-bookings/:id/pay
|
* POST /api/v1/csv-bookings/:id/pay
|
||||||
*/
|
*/
|
||||||
@Post(':id/pay')
|
@Post(':id/pay')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('ADMIN', 'MANAGER', 'USER')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
@ -464,6 +477,8 @@ export class CsvBookingsController {
|
|||||||
* POST /api/v1/csv-bookings/:id/confirm-payment
|
* POST /api/v1/csv-bookings/:id/confirm-payment
|
||||||
*/
|
*/
|
||||||
@Post(':id/confirm-payment')
|
@Post(':id/confirm-payment')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('ADMIN', 'MANAGER', 'USER')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
@ -507,6 +522,8 @@ export class CsvBookingsController {
|
|||||||
* POST /api/v1/csv-bookings/:id/declare-transfer
|
* POST /api/v1/csv-bookings/:id/declare-transfer
|
||||||
*/
|
*/
|
||||||
@Post(':id/declare-transfer')
|
@Post(':id/declare-transfer')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('ADMIN', 'MANAGER', 'USER')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
@ -569,6 +586,8 @@ export class CsvBookingsController {
|
|||||||
* PATCH /api/v1/csv-bookings/:id/cancel
|
* PATCH /api/v1/csv-bookings/:id/cancel
|
||||||
*/
|
*/
|
||||||
@Patch(':id/cancel')
|
@Patch(':id/cancel')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('ADMIN', 'MANAGER', 'USER')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
@ -598,6 +617,8 @@ export class CsvBookingsController {
|
|||||||
* DELETE /api/v1/csv-bookings/:id
|
* DELETE /api/v1/csv-bookings/:id
|
||||||
*/
|
*/
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('ADMIN', 'MANAGER', 'USER')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
@ -623,6 +644,8 @@ export class CsvBookingsController {
|
|||||||
* PATCH /api/v1/csv-bookings/:id/details
|
* PATCH /api/v1/csv-bookings/:id/details
|
||||||
*/
|
*/
|
||||||
@Patch(':id/details')
|
@Patch(':id/details')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('ADMIN', 'MANAGER', 'USER')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
@ -654,6 +677,8 @@ export class CsvBookingsController {
|
|||||||
* PATCH /api/v1/csv-bookings/:id/rate
|
* PATCH /api/v1/csv-bookings/:id/rate
|
||||||
*/
|
*/
|
||||||
@Patch(':id/rate')
|
@Patch(':id/rate')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('ADMIN', 'MANAGER', 'USER')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
@ -685,9 +710,21 @@ export class CsvBookingsController {
|
|||||||
* POST /api/v1/csv-bookings/:id/documents
|
* POST /api/v1/csv-bookings/:id/documents
|
||||||
*/
|
*/
|
||||||
@Post(':id/documents')
|
@Post(':id/documents')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('ADMIN', 'MANAGER', 'USER')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseInterceptors(FilesInterceptor('documents', 10))
|
@UseInterceptors(
|
||||||
|
FilesInterceptor('documents', 10, {
|
||||||
|
limits: {
|
||||||
|
fileSize: 10 * 1024 * 1024,
|
||||||
|
files: 10,
|
||||||
|
fields: 40,
|
||||||
|
parts: 50,
|
||||||
|
fieldSize: 64 * 1024,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
@ApiConsumes('multipart/form-data')
|
@ApiConsumes('multipart/form-data')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Add documents to an existing booking',
|
summary: 'Add documents to an existing booking',
|
||||||
@ -741,9 +778,15 @@ export class CsvBookingsController {
|
|||||||
* PUT /api/v1/csv-bookings/:bookingId/documents/:documentId
|
* PUT /api/v1/csv-bookings/:bookingId/documents/:documentId
|
||||||
*/
|
*/
|
||||||
@Patch(':bookingId/documents/:documentId')
|
@Patch(':bookingId/documents/:documentId')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('ADMIN', 'MANAGER', 'USER')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseInterceptors(FilesInterceptor('document', 1))
|
@UseInterceptors(
|
||||||
|
FilesInterceptor('document', 1, {
|
||||||
|
limits: { fileSize: 10 * 1024 * 1024, files: 1, fields: 10, parts: 11, fieldSize: 64 * 1024 },
|
||||||
|
})
|
||||||
|
)
|
||||||
@ApiConsumes('multipart/form-data')
|
@ApiConsumes('multipart/form-data')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Replace a document in a booking',
|
summary: 'Replace a document in a booking',
|
||||||
@ -810,6 +853,8 @@ export class CsvBookingsController {
|
|||||||
* DELETE /api/v1/csv-bookings/:bookingId/documents/:documentId
|
* DELETE /api/v1/csv-bookings/:bookingId/documents/:documentId
|
||||||
*/
|
*/
|
||||||
@Delete(':bookingId/documents/:documentId')
|
@Delete(':bookingId/documents/:documentId')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('ADMIN', 'MANAGER', 'USER')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@ -0,0 +1,86 @@
|
|||||||
|
import { ExecutionContext, INestApplication } from '@nestjs/common';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { CsvBookingsController } from './csv-bookings.controller';
|
||||||
|
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||||
|
import { CsvBookingService } from '../services/csv-booking.service';
|
||||||
|
import { SubscriptionService } from '../services/subscription.service';
|
||||||
|
import { SHIPMENT_COUNTER_PORT } from '@domain/ports/out/shipment-counter.port';
|
||||||
|
import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository';
|
||||||
|
|
||||||
|
describe('CSV booking HTTP security', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
const createBooking = jest.fn(async () => ({ id: 'booking' }));
|
||||||
|
const getUserBookings = jest.fn(async () => ({ bookings: [] }));
|
||||||
|
beforeAll(async () => {
|
||||||
|
const module = await Test.createTestingModule({
|
||||||
|
controllers: [CsvBookingsController],
|
||||||
|
providers: [
|
||||||
|
{ provide: CsvBookingService, useValue: { createBooking, getUserBookings } },
|
||||||
|
{
|
||||||
|
provide: SubscriptionService,
|
||||||
|
useValue: {
|
||||||
|
getOrCreateSubscription: async () => ({ plan: { maxShipmentsPerYear: -1 } }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ provide: ConfigService, useValue: {} },
|
||||||
|
{ provide: SHIPMENT_COUNTER_PORT, useValue: {} },
|
||||||
|
{ provide: ORGANIZATION_REPOSITORY, useValue: {} },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.overrideGuard(JwtAuthGuard)
|
||||||
|
.useValue({
|
||||||
|
canActivate: (context: ExecutionContext) => {
|
||||||
|
const req = context.switchToHttp().getRequest();
|
||||||
|
req.user = {
|
||||||
|
id: 'user',
|
||||||
|
organizationId: 'org',
|
||||||
|
role: req.headers['x-test-role'] || 'USER',
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.compile();
|
||||||
|
app = module.createNestApplication({ logger: false });
|
||||||
|
await app.init();
|
||||||
|
await app.listen(0, '127.0.0.1');
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
await app?.close();
|
||||||
|
});
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
it('rejects VIEWER mutations before invoking the booking service', async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/csv-bookings')
|
||||||
|
.set('x-test-role', 'VIEWER')
|
||||||
|
.attach('documents', Buffer.from('document'), 'test.pdf')
|
||||||
|
.expect(403);
|
||||||
|
expect(createBooking).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('preserves VIEWER reads', async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get('/csv-bookings')
|
||||||
|
.set('x-test-role', 'VIEWER')
|
||||||
|
.expect(200);
|
||||||
|
expect(getUserBookings).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('rejects organization-wide reads for an ordinary member', async () => {
|
||||||
|
await request(app.getHttpServer()).get('/csv-bookings/organization/all').expect(403);
|
||||||
|
});
|
||||||
|
it('rejects oversized documents before invoking the service', async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/csv-bookings')
|
||||||
|
.attach('documents', Buffer.alloc(10 * 1024 * 1024 + 1), 'large.pdf')
|
||||||
|
.expect(413);
|
||||||
|
expect(createBooking).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('preserves permitted uploads', async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/csv-bookings')
|
||||||
|
.attach('documents', Buffer.from('document'), 'test.pdf')
|
||||||
|
.expect(201);
|
||||||
|
expect(createBooking).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -152,7 +152,7 @@ export class NotificationsController {
|
|||||||
throw new NotFoundException('Notification not found');
|
throw new NotFoundException('Notification not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.notificationService.markAsRead(id);
|
await this.notificationService.markAsRead(id, user.id);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,67 @@
|
|||||||
|
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Organization, OrganizationType } from '@domain/entities/organization.entity';
|
||||||
|
import { OrganizationRepository } from '@domain/ports/out/organization.repository';
|
||||||
|
import { UserRepository } from '@domain/ports/out/user.repository';
|
||||||
|
import { OrganizationsController } from './organizations.controller';
|
||||||
|
import { NotificationService } from '../services/notification.service';
|
||||||
|
import { UserPayload } from '../decorators/current-user.decorator';
|
||||||
|
|
||||||
|
describe('OrganizationsController tenant authorization', () => {
|
||||||
|
const actor = (role: string): UserPayload => ({
|
||||||
|
id: 'user-id',
|
||||||
|
email: 'manager@example.org',
|
||||||
|
role,
|
||||||
|
organizationId: 'own-org',
|
||||||
|
firstName: 'Test',
|
||||||
|
lastName: 'User',
|
||||||
|
});
|
||||||
|
const makeOrganization = (id: string) =>
|
||||||
|
Organization.create({
|
||||||
|
id,
|
||||||
|
name: 'Original',
|
||||||
|
type: OrganizationType.FREIGHT_FORWARDER,
|
||||||
|
address: { street: '1 rue Test', city: 'Paris', postalCode: '75001', country: 'FR' },
|
||||||
|
documents: [],
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
const findById = jest.fn();
|
||||||
|
const save = jest.fn(async (organization: Organization) => organization);
|
||||||
|
const controller = new OrganizationsController(
|
||||||
|
{ findById, save } as unknown as OrganizationRepository,
|
||||||
|
{} as UserRepository,
|
||||||
|
{} as NotificationService
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
it.each(['MANAGER', 'manager', 'USER', 'VIEWER'])(
|
||||||
|
'rejects foreign organization for %s',
|
||||||
|
async role => {
|
||||||
|
const target = makeOrganization('other-org');
|
||||||
|
findById.mockResolvedValue(target);
|
||||||
|
await expect(
|
||||||
|
controller.updateOrganization(target.id, { name: 'Changed' }, actor(role))
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
expect(target.name).toBe('Original');
|
||||||
|
expect(save).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['MANAGER', 'own-org'],
|
||||||
|
['ADMIN', 'other-org'],
|
||||||
|
])('allows %s to update %s', async (role, id) => {
|
||||||
|
findById.mockResolvedValue(makeOrganization(id));
|
||||||
|
const result = await controller.updateOrganization(id, { name: 'Changed' }, actor(role));
|
||||||
|
expect(result.name).toBe('Changed');
|
||||||
|
expect(save).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves missing organization response', async () => {
|
||||||
|
findById.mockResolvedValue(null);
|
||||||
|
await expect(
|
||||||
|
controller.updateOrganization('missing', {}, actor('ADMIN'))
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
expect(save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -42,6 +42,7 @@ import {
|
|||||||
ORGANIZATION_REPOSITORY,
|
ORGANIZATION_REPOSITORY,
|
||||||
} from '@domain/ports/out/organization.repository';
|
} from '@domain/ports/out/organization.repository';
|
||||||
import { Organization, OrganizationType } from '@domain/entities/organization.entity';
|
import { Organization, OrganizationType } from '@domain/entities/organization.entity';
|
||||||
|
import { UserRole } from '@domain/entities/user.entity';
|
||||||
import { NotificationType, NotificationPriority } from '@domain/entities/notification.entity';
|
import { NotificationType, NotificationPriority } from '@domain/entities/notification.entity';
|
||||||
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
||||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||||
@ -251,7 +252,7 @@ export class OrganizationsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Authorization: Managers can only update their own organization
|
// Authorization: Managers can only update their own organization
|
||||||
if (user.role === 'manager' && organization.id !== user.organizationId) {
|
if (user.role !== UserRole.ADMIN && organization.id !== user.organizationId) {
|
||||||
throw new ForbiddenException('You can only update your own organization');
|
throw new ForbiddenException('You can only update your own organization');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -24,6 +24,8 @@ import {
|
|||||||
Req,
|
Req,
|
||||||
Inject,
|
Inject,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
|
BadRequestException,
|
||||||
|
InternalServerErrorException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
ApiTags,
|
||||||
@ -269,7 +271,7 @@ export class SubscriptionsController {
|
|||||||
const rawBody = req.rawBody;
|
const rawBody = req.rawBody;
|
||||||
if (!rawBody) {
|
if (!rawBody) {
|
||||||
this.logger.error('No raw body found in request');
|
this.logger.error('No raw body found in request');
|
||||||
return { received: false };
|
throw new BadRequestException('Missing webhook body');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -277,7 +279,7 @@ export class SubscriptionsController {
|
|||||||
return { received: true };
|
return { received: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Webhook processing failed', error);
|
this.logger.error('Webhook processing failed', error);
|
||||||
return { received: false };
|
throw new InternalServerErrorException('Webhook processing failed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -160,11 +160,6 @@ export class UsersController {
|
|||||||
|
|
||||||
this.logger.log(`User created successfully: ${savedUser.id}`);
|
this.logger.log(`User created successfully: ${savedUser.id}`);
|
||||||
|
|
||||||
// TODO: Send invitation email with temporary password
|
|
||||||
this.logger.warn(
|
|
||||||
`TODO: Send invitation email to ${dto.email} with temp password: ${tempPassword}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return UserMapper.toDto(savedUser);
|
return UserMapper.toDto(savedUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -253,6 +248,10 @@ export class UsersController {
|
|||||||
throw new BadRequestException('You cannot change your own role');
|
throw new BadRequestException('You cannot change your own role');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (user.role === DomainUserRole.ADMIN && currentUser.role !== DomainUserRole.ADMIN) {
|
||||||
|
throw new ForbiddenException('Only platform administrators can update ADMIN users');
|
||||||
|
}
|
||||||
|
|
||||||
// Authorization: Only ADMIN can assign ADMIN role
|
// Authorization: Only ADMIN can assign ADMIN role
|
||||||
if (dto.role === 'ADMIN' && currentUser.role !== 'ADMIN') {
|
if (dto.role === 'ADMIN' && currentUser.role !== 'ADMIN') {
|
||||||
throw new ForbiddenException('Only platform administrators can assign ADMIN role');
|
throw new ForbiddenException('Only platform administrators can assign ADMIN role');
|
||||||
|
|||||||
@ -0,0 +1,80 @@
|
|||||||
|
import { ForbiddenException, Logger } from '@nestjs/common';
|
||||||
|
import { User, UserRole } from '@domain/entities/user.entity';
|
||||||
|
import { UserRepository } from '@domain/ports/out/user.repository';
|
||||||
|
import { UsersController } from './users.controller';
|
||||||
|
import { SubscriptionService } from '../services/subscription.service';
|
||||||
|
import { UserPayload } from '../decorators/current-user.decorator';
|
||||||
|
import { UserRole as DtoUserRole } from '../dto/user.dto';
|
||||||
|
|
||||||
|
describe('administrator target protection', () => {
|
||||||
|
it('does not log a temporary password when creating an account', async () => {
|
||||||
|
const log = jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||||
|
const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||||
|
try {
|
||||||
|
const controller = new UsersController(
|
||||||
|
{
|
||||||
|
findByEmail: async () => null,
|
||||||
|
save: async (user: User) => user,
|
||||||
|
} as unknown as UserRepository,
|
||||||
|
{} as SubscriptionService
|
||||||
|
);
|
||||||
|
await controller.createUser(
|
||||||
|
{
|
||||||
|
email: 'new@example.org',
|
||||||
|
firstName: 'New',
|
||||||
|
lastName: 'User',
|
||||||
|
organizationId: 'org-1',
|
||||||
|
role: DtoUserRole.USER,
|
||||||
|
password: 'test-only-Temporary-password-42',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'admin',
|
||||||
|
email: 'admin@example.org',
|
||||||
|
role: 'ADMIN',
|
||||||
|
organizationId: 'org-1',
|
||||||
|
firstName: 'A',
|
||||||
|
lastName: 'B',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(JSON.stringify([...log.mock.calls, ...warn.mock.calls])).not.toContain(
|
||||||
|
'test-only-Temporary-password-42'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
log.mockRestore();
|
||||||
|
warn.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const actor: UserPayload = {
|
||||||
|
id: 'manager',
|
||||||
|
role: 'MANAGER',
|
||||||
|
organizationId: 'org-1',
|
||||||
|
email: 'manager@example.org',
|
||||||
|
firstName: 'Test',
|
||||||
|
lastName: 'Manager',
|
||||||
|
};
|
||||||
|
it.each([UserRole.ADMIN, UserRole.USER])('enforces target hierarchy for %s', async role => {
|
||||||
|
const user = User.create({
|
||||||
|
id: 'target',
|
||||||
|
role,
|
||||||
|
organizationId: actor.organizationId,
|
||||||
|
email: 'target@example.org',
|
||||||
|
firstName: 'Original',
|
||||||
|
lastName: 'User',
|
||||||
|
passwordHash: 'test-hash',
|
||||||
|
});
|
||||||
|
const save = jest.fn(async () => user);
|
||||||
|
const controller = new UsersController(
|
||||||
|
{ findById: jest.fn(async () => user), save } as unknown as UserRepository,
|
||||||
|
{} as SubscriptionService
|
||||||
|
);
|
||||||
|
const result = controller.updateUser(user.id, { firstName: 'Changed' }, actor);
|
||||||
|
if (role === UserRole.ADMIN) {
|
||||||
|
await expect(result).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
expect(save).not.toHaveBeenCalled();
|
||||||
|
expect(user.firstName).toBe('Original');
|
||||||
|
} else {
|
||||||
|
await expect(result).resolves.toHaveProperty('firstName', 'Changed');
|
||||||
|
expect(save).toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -537,12 +537,6 @@ export class CsvBookingResponseDto {
|
|||||||
})
|
})
|
||||||
documents: CsvBookingDocumentDto[];
|
documents: CsvBookingDocumentDto[];
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Confirmation token for accept/reject actions',
|
|
||||||
example: 'abc123-def456-ghi789',
|
|
||||||
})
|
|
||||||
confirmationToken: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Booking request timestamp',
|
description: 'Booking request timestamp',
|
||||||
example: '2025-10-23T14:30:00Z',
|
example: '2025-10-23T14:30:00Z',
|
||||||
|
|||||||
@ -0,0 +1,70 @@
|
|||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { Socket } from 'socket.io';
|
||||||
|
import { NotificationsGateway } from './notifications.gateway';
|
||||||
|
import { JwtStrategy } from '../auth/jwt.strategy';
|
||||||
|
import { AuthService } from '../auth/auth.service';
|
||||||
|
import { NotificationService } from '../services/notification.service';
|
||||||
|
|
||||||
|
describe('notification socket sessions', () => {
|
||||||
|
const jwt = new JwtService({ secret: 'test-only-socket-secret' });
|
||||||
|
const validateUser = jest.fn();
|
||||||
|
const notifications = {
|
||||||
|
getUnreadCount: jest.fn(async () => 0),
|
||||||
|
getRecentNotifications: jest.fn(async () => []),
|
||||||
|
markAllAsRead: jest.fn(),
|
||||||
|
};
|
||||||
|
let gateway: NotificationsGateway;
|
||||||
|
const socket = (token: string) =>
|
||||||
|
({
|
||||||
|
id: 'socket-1',
|
||||||
|
data: {},
|
||||||
|
handshake: { headers: {}, query: {}, auth: { token } },
|
||||||
|
join: jest.fn(),
|
||||||
|
emit: jest.fn(),
|
||||||
|
disconnect: jest.fn(),
|
||||||
|
}) as unknown as Socket;
|
||||||
|
const token = (type = 'access', expiresIn = 300) =>
|
||||||
|
jwt.sign({ sub: 'user-1', type }, { expiresIn });
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
validateUser.mockResolvedValue({ id: 'user-1', organizationId: 'org-1' });
|
||||||
|
const strategy = new JwtStrategy(new ConfigService({ JWT_SECRET: 'test-only-socket-secret' }), {
|
||||||
|
validateUser,
|
||||||
|
} as unknown as AuthService);
|
||||||
|
gateway = new NotificationsGateway(
|
||||||
|
jwt,
|
||||||
|
notifications as unknown as NotificationService,
|
||||||
|
strategy
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['refresh', 'unknown'])('rejects %s tokens before any data is sent', async type => {
|
||||||
|
const client = socket(token(type));
|
||||||
|
await gateway.handleConnection(client);
|
||||||
|
expect(client.disconnect).toHaveBeenCalled();
|
||||||
|
expect(client.emit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects expired and disabled sessions', async () => {
|
||||||
|
const expired = socket(token('access', -1));
|
||||||
|
await gateway.handleConnection(expired);
|
||||||
|
expect(expired.emit).not.toHaveBeenCalled();
|
||||||
|
validateUser.mockResolvedValue(null);
|
||||||
|
const disabled = socket(token());
|
||||||
|
await gateway.handleConnection(disabled);
|
||||||
|
expect(disabled.emit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rechecks the account on messages after a valid connection', async () => {
|
||||||
|
const client = socket(token());
|
||||||
|
await gateway.handleConnection(client);
|
||||||
|
expect(client.emit).toHaveBeenCalledWith('unread_count', { count: 0 });
|
||||||
|
validateUser.mockResolvedValue(null);
|
||||||
|
const result = await gateway.handleMarkAllAsRead(client);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(notifications.markAllAsRead).not.toHaveBeenCalled();
|
||||||
|
expect(client.disconnect).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -14,8 +14,9 @@ import {
|
|||||||
MessageBody,
|
MessageBody,
|
||||||
} from '@nestjs/websockets';
|
} from '@nestjs/websockets';
|
||||||
import { Server, Socket } from 'socket.io';
|
import { Server, Socket } from 'socket.io';
|
||||||
import { Logger, UseGuards } from '@nestjs/common';
|
import { Logger, UseGuards, UnauthorizedException } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { JwtStrategy, JwtPayload } from '../auth/jwt.strategy';
|
||||||
import { NotificationService } from '../services/notification.service';
|
import { NotificationService } from '../services/notification.service';
|
||||||
import { Notification } from '@domain/entities/notification.entity';
|
import { Notification } from '@domain/entities/notification.entity';
|
||||||
import { notificationTarget } from '@domain/services/notification-target';
|
import { notificationTarget } from '@domain/services/notification-target';
|
||||||
@ -36,11 +37,13 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
|
|||||||
server: Server;
|
server: Server;
|
||||||
|
|
||||||
private readonly logger = new Logger(NotificationsGateway.name);
|
private readonly logger = new Logger(NotificationsGateway.name);
|
||||||
|
private readonly connections = new Map<string, Socket>();
|
||||||
private userSockets: Map<string, Set<string>> = new Map(); // userId -> Set of socket IDs
|
private userSockets: Map<string, Set<string>> = new Map(); // userId -> Set of socket IDs
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly notificationService: NotificationService
|
private readonly notificationService: NotificationService,
|
||||||
|
private readonly jwtStrategy: JwtStrategy
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -57,8 +60,9 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify JWT token
|
// Verify JWT token
|
||||||
const payload = await this.jwtService.verifyAsync(token);
|
const user = await this.authenticate(client);
|
||||||
const userId = payload.sub;
|
const userId = user.id;
|
||||||
|
this.connections.set(client.id, client);
|
||||||
|
|
||||||
// Store socket connection for user
|
// Store socket connection for user
|
||||||
if (!this.userSockets.has(userId)) {
|
if (!this.userSockets.has(userId)) {
|
||||||
@ -68,7 +72,7 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
|
|||||||
|
|
||||||
// Store user ID in socket data for later use
|
// Store user ID in socket data for later use
|
||||||
client.data.userId = userId;
|
client.data.userId = userId;
|
||||||
client.data.organizationId = payload.organizationId;
|
client.data.organizationId = user.organizationId;
|
||||||
|
|
||||||
// Join user-specific room
|
// Join user-specific room
|
||||||
client.join(`user:${userId}`);
|
client.join(`user:${userId}`);
|
||||||
@ -97,6 +101,7 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
|
|||||||
* Handle client disconnection
|
* Handle client disconnection
|
||||||
*/
|
*/
|
||||||
handleDisconnect(client: Socket) {
|
handleDisconnect(client: Socket) {
|
||||||
|
this.connections.delete(client.id);
|
||||||
const userId = client.data.userId;
|
const userId = client.data.userId;
|
||||||
if (userId && this.userSockets.has(userId)) {
|
if (userId && this.userSockets.has(userId)) {
|
||||||
this.userSockets.get(userId)!.delete(client.id);
|
this.userSockets.get(userId)!.delete(client.id);
|
||||||
@ -116,12 +121,12 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
|
|||||||
@MessageBody() data: { notificationId: string }
|
@MessageBody() data: { notificationId: string }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const userId = client.data.userId;
|
const userId = (await this.authenticate(client)).id;
|
||||||
await this.notificationService.markAsRead(data.notificationId);
|
await this.notificationService.markAsRead(data.notificationId, userId);
|
||||||
|
|
||||||
// Send updated unread count
|
// Send updated unread count
|
||||||
const unreadCount = await this.notificationService.getUnreadCount(userId);
|
const unreadCount = await this.notificationService.getUnreadCount(userId);
|
||||||
this.emitToUser(userId, 'unread_count', { count: unreadCount });
|
await this.emitToUser(userId, 'unread_count', { count: unreadCount });
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@ -136,11 +141,11 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
|
|||||||
@SubscribeMessage('mark_all_as_read')
|
@SubscribeMessage('mark_all_as_read')
|
||||||
async handleMarkAllAsRead(@ConnectedSocket() client: Socket) {
|
async handleMarkAllAsRead(@ConnectedSocket() client: Socket) {
|
||||||
try {
|
try {
|
||||||
const userId = client.data.userId;
|
const userId = (await this.authenticate(client)).id;
|
||||||
await this.notificationService.markAllAsRead(userId);
|
await this.notificationService.markAllAsRead(userId);
|
||||||
|
|
||||||
// Send updated unread count (should be 0)
|
// Send updated unread count (should be 0)
|
||||||
this.emitToUser(userId, 'unread_count', { count: 0 });
|
await this.emitToUser(userId, 'unread_count', { count: 0 });
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@ -155,7 +160,7 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
|
|||||||
@SubscribeMessage('get_unread_count')
|
@SubscribeMessage('get_unread_count')
|
||||||
async handleGetUnreadCount(@ConnectedSocket() client: Socket) {
|
async handleGetUnreadCount(@ConnectedSocket() client: Socket) {
|
||||||
try {
|
try {
|
||||||
const userId = client.data.userId;
|
const userId = (await this.authenticate(client)).id;
|
||||||
const unreadCount = await this.notificationService.getUnreadCount(userId);
|
const unreadCount = await this.notificationService.getUnreadCount(userId);
|
||||||
return { count: unreadCount };
|
return { count: unreadCount };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@ -171,11 +176,11 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
|
|||||||
const notificationDto = this.mapNotificationToDto(notification);
|
const notificationDto = this.mapNotificationToDto(notification);
|
||||||
|
|
||||||
// Emit to all connected sockets for this user
|
// Emit to all connected sockets for this user
|
||||||
this.emitToUser(userId, 'new_notification', { notification: notificationDto });
|
await this.emitToUser(userId, 'new_notification', { notification: notificationDto });
|
||||||
|
|
||||||
// Update unread count
|
// Update unread count
|
||||||
const unreadCount = await this.notificationService.getUnreadCount(userId);
|
const unreadCount = await this.notificationService.getUnreadCount(userId);
|
||||||
this.emitToUser(userId, 'unread_count', { count: unreadCount });
|
await this.emitToUser(userId, 'unread_count', { count: unreadCount });
|
||||||
|
|
||||||
this.logger.log(`Notification sent to user ${userId}: ${notification.title}`);
|
this.logger.log(`Notification sent to user ${userId}: ${notification.title}`);
|
||||||
}
|
}
|
||||||
@ -185,9 +190,16 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
|
|||||||
*/
|
*/
|
||||||
async broadcastToOrganization(organizationId: string, notification: Notification) {
|
async broadcastToOrganization(organizationId: string, notification: Notification) {
|
||||||
const notificationDto = this.mapNotificationToDto(notification);
|
const notificationDto = this.mapNotificationToDto(notification);
|
||||||
this.server.to(`org:${organizationId}`).emit('new_notification', {
|
for (const client of this.connections.values()) {
|
||||||
notification: notificationDto,
|
try {
|
||||||
});
|
const user = await this.authenticate(client);
|
||||||
|
if (user.organizationId === organizationId) {
|
||||||
|
client.emit('new_notification', { notification: notificationDto });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
client.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.logger.log(`Notification broadcasted to organization ${organizationId}`);
|
this.logger.log(`Notification broadcasted to organization ${organizationId}`);
|
||||||
}
|
}
|
||||||
@ -195,8 +207,36 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
|
|||||||
/**
|
/**
|
||||||
* Helper: Emit event to all sockets of a user
|
* Helper: Emit event to all sockets of a user
|
||||||
*/
|
*/
|
||||||
private emitToUser(userId: string, event: string, data: any) {
|
private async emitToUser(userId: string, event: string, data: unknown) {
|
||||||
this.server.to(`user:${userId}`).emit(event, data);
|
for (const socketId of this.userSockets.get(userId) ?? []) {
|
||||||
|
const client = this.connections.get(socketId);
|
||||||
|
if (!client) continue;
|
||||||
|
try {
|
||||||
|
const user = await this.authenticate(client);
|
||||||
|
if (user.id === userId) client.emit(event, data);
|
||||||
|
} catch {
|
||||||
|
client.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async authenticate(client: Socket) {
|
||||||
|
try {
|
||||||
|
const token = this.extractToken(client);
|
||||||
|
if (!token) throw new UnauthorizedException();
|
||||||
|
const payload = await this.jwtService.verifyAsync<JwtPayload>(token);
|
||||||
|
if (
|
||||||
|
typeof payload.sub !== 'string' ||
|
||||||
|
!Number.isFinite(payload.exp) ||
|
||||||
|
payload.exp! * 1000 <= Date.now()
|
||||||
|
) {
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
return await this.jwtStrategy.validate(payload);
|
||||||
|
} catch {
|
||||||
|
client.disconnect();
|
||||||
|
throw new UnauthorizedException('Invalid or expired session');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
@ -17,6 +18,7 @@ import { NOTIFICATION_REPOSITORY } from '@domain/ports/out/notification.reposito
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
AuthModule,
|
||||||
TypeOrmModule.forFeature([NotificationOrmEntity]),
|
TypeOrmModule.forFeature([NotificationOrmEntity]),
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
|
|||||||
@ -0,0 +1,24 @@
|
|||||||
|
import { CsvBookingService } from './csv-booking.service';
|
||||||
|
import { CsvBooking } from '@domain/entities/csv-booking.entity';
|
||||||
|
|
||||||
|
describe('customer booking response', () => {
|
||||||
|
it('omits carrier capabilities while preserving booking information', () => {
|
||||||
|
const booking = {
|
||||||
|
id: 'booking-1',
|
||||||
|
primaryCurrency: 'EUR',
|
||||||
|
confirmationToken: 'carrier-secret',
|
||||||
|
origin: { getValue: () => 'FRLEH' },
|
||||||
|
destination: { getValue: () => 'CNSHA' },
|
||||||
|
documents: [],
|
||||||
|
getRouteDescription: () => 'FRLEH → CNSHA',
|
||||||
|
isExpired: () => false,
|
||||||
|
getPriceInCurrency: () => 100,
|
||||||
|
} as unknown as CsvBooking;
|
||||||
|
const service = Object.create(CsvBookingService.prototype) as CsvBookingService;
|
||||||
|
const response = service['toResponseDto'](booking);
|
||||||
|
expect(response.id).toBe('booking-1');
|
||||||
|
expect(response.price).toBe(100);
|
||||||
|
expect(response).not.toHaveProperty('confirmationToken');
|
||||||
|
expect(JSON.stringify(response)).not.toContain('carrier-secret');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1607,7 +1607,6 @@ export class CsvBookingService {
|
|||||||
containerType: booking.containerType,
|
containerType: booking.containerType,
|
||||||
status: booking.status,
|
status: booking.status,
|
||||||
documents: booking.documents.map(this.toDocumentDto),
|
documents: booking.documents.map(this.toDocumentDto),
|
||||||
confirmationToken: booking.confirmationToken,
|
|
||||||
requestedAt: booking.requestedAt,
|
requestedAt: booking.requestedAt,
|
||||||
respondedAt: booking.respondedAt || null,
|
respondedAt: booking.respondedAt || null,
|
||||||
notes: booking.notes,
|
notes: booking.notes,
|
||||||
|
|||||||
@ -0,0 +1,57 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { InvitationService } from './invitation.service';
|
||||||
|
import { SubscriptionService } from './subscription.service';
|
||||||
|
import { InvitationToken } from '@domain/entities/invitation-token.entity';
|
||||||
|
import { UserRole } from '@domain/entities/user.entity';
|
||||||
|
import { InvitationTokenRepository } from '@domain/ports/out/invitation-token.repository';
|
||||||
|
import { UserRepository } from '@domain/ports/out/user.repository';
|
||||||
|
import { OrganizationRepository } from '@domain/ports/out/organization.repository';
|
||||||
|
import { EmailPort } from '@domain/ports/out/email.port';
|
||||||
|
|
||||||
|
describe('invitation secret handling', () => {
|
||||||
|
it('keeps the token in the email but out of success and failure logs', async () => {
|
||||||
|
const log = jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||||
|
const error = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||||
|
try {
|
||||||
|
const invitation = InvitationToken.create({
|
||||||
|
id: 'invite-id',
|
||||||
|
token: 'test-only-invitation-secret',
|
||||||
|
email: 'user@example.org',
|
||||||
|
firstName: 'Test',
|
||||||
|
lastName: 'User',
|
||||||
|
role: UserRole.USER,
|
||||||
|
organizationId: 'org',
|
||||||
|
invitedById: 'admin',
|
||||||
|
expiresAt: new Date(Date.now() + 60_000),
|
||||||
|
});
|
||||||
|
const send = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const service = new InvitationService(
|
||||||
|
{
|
||||||
|
findByToken: async () => invitation,
|
||||||
|
update: async () => invitation,
|
||||||
|
} as unknown as InvitationTokenRepository,
|
||||||
|
{
|
||||||
|
findById: async () => ({ firstName: 'Test', lastName: 'Admin' }),
|
||||||
|
} as unknown as UserRepository,
|
||||||
|
{ findById: async () => ({ name: 'Company' }) } as unknown as OrganizationRepository,
|
||||||
|
{ sendInvitationWithToken: send } as unknown as EmailPort,
|
||||||
|
new ConfigService({ FRONTEND_URL: 'https://example.org' }),
|
||||||
|
{} as SubscriptionService
|
||||||
|
);
|
||||||
|
await service['sendInvitationEmail'](invitation);
|
||||||
|
expect(send.mock.calls[0][5]).toBe(
|
||||||
|
'https://example.org/register?token=test-only-invitation-secret'
|
||||||
|
);
|
||||||
|
send.mockRejectedValue(new Error('test-only-invitation-secret'));
|
||||||
|
await expect(service['sendInvitationEmail'](invitation)).rejects.toThrow();
|
||||||
|
await service.markInvitationAsUsed(invitation.token);
|
||||||
|
expect(JSON.stringify([...log.mock.calls, ...error.mock.calls])).not.toContain(
|
||||||
|
invitation.token
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
log.mockRestore();
|
||||||
|
error.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -109,10 +109,8 @@ export class InvitationService {
|
|||||||
|
|
||||||
// Send invitation email (async - don't block on email sending)
|
// Send invitation email (async - don't block on email sending)
|
||||||
this.logger.log(`[INVITATION] About to send email to ${email}...`);
|
this.logger.log(`[INVITATION] About to send email to ${email}...`);
|
||||||
this.sendInvitationEmail(savedInvitation).catch(err => {
|
this.sendInvitationEmail(savedInvitation).catch(() => {
|
||||||
this.logger.error(`[INVITATION] ❌ Failed to send invitation email to ${email}`, err);
|
this.logger.error(`Invitation email delivery failed: ${savedInvitation.id}`);
|
||||||
this.logger.error(`[INVITATION] Error message: ${err?.message}`);
|
|
||||||
this.logger.error(`[INVITATION] Error stack: ${err?.stack?.substring(0, 500)}`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`Invitation created successfully for ${email}`);
|
this.logger.log(`Invitation created successfully for ${email}`);
|
||||||
@ -151,7 +149,7 @@ export class InvitationService {
|
|||||||
|
|
||||||
await this.invitationRepository.update(invitation);
|
await this.invitationRepository.update(invitation);
|
||||||
|
|
||||||
this.logger.log(`Invitation ${token} marked as used`);
|
this.logger.log(`Invitation ${invitation.id} marked as used`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -178,7 +176,6 @@ export class InvitationService {
|
|||||||
const invitationLink = `${frontendUrl}/register?token=${invitation.token}`;
|
const invitationLink = `${frontendUrl}/register?token=${invitation.token}`;
|
||||||
|
|
||||||
this.logger.log(`[INVITATION] Frontend URL: ${frontendUrl}`);
|
this.logger.log(`[INVITATION] Frontend URL: ${frontendUrl}`);
|
||||||
this.logger.log(`[INVITATION] Invitation link: ${invitationLink}`);
|
|
||||||
|
|
||||||
// Get organization details
|
// Get organization details
|
||||||
this.logger.log(`[INVITATION] Fetching organization ${invitation.organizationId}...`);
|
this.logger.log(`[INVITATION] Fetching organization ${invitation.organizationId}...`);
|
||||||
@ -214,11 +211,7 @@ export class InvitationService {
|
|||||||
|
|
||||||
this.logger.log(`[INVITATION] ✅ Email sent successfully to ${invitation.email}`);
|
this.logger.log(`[INVITATION] ✅ Email sent successfully to ${invitation.email}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(`Invitation email delivery failed: ${invitation.id}`);
|
||||||
`[INVITATION] ❌ Failed to send invitation email to ${invitation.email}`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
this.logger.error(`[INVITATION] Error details: ${JSON.stringify(error, null, 2)}`);
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,38 @@
|
|||||||
|
import { NotificationService } from './notification.service';
|
||||||
|
import { NotificationRepository } from '@domain/ports/out/notification.repository';
|
||||||
|
import { TypeOrmNotificationRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-notification.repository';
|
||||||
|
import { NotificationOrmEntity } from '@infrastructure/persistence/typeorm/entities/notification.orm-entity';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
describe('notification mutation boundary', () => {
|
||||||
|
const owner = 'bd223f0d-89be-4f98-aaf4-0ab1353594e1';
|
||||||
|
const other = 'bd223f0d-89be-4f98-aaf4-0ab1353594e2';
|
||||||
|
const id = 'bd223f0d-89be-4f98-aaf4-0ab1353594e3';
|
||||||
|
it.each([{ read: false }, [], null, '', 'invalid'])(
|
||||||
|
'rejects malformed notification criteria %j',
|
||||||
|
async value => {
|
||||||
|
const markAsRead = jest.fn();
|
||||||
|
const service = new NotificationService({ markAsRead } as unknown as NotificationRepository);
|
||||||
|
await expect(service.markAsRead(value as unknown as string, owner)).rejects.toThrow();
|
||||||
|
expect(markAsRead).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
it('restricts an update to the authenticated recipient', async () => {
|
||||||
|
const row = { id, user_id: owner, read: false };
|
||||||
|
const update = jest.fn(async (criteria: { id: string; user_id: string }) => {
|
||||||
|
if (row.id === criteria.id && row.user_id === criteria.user_id) row.read = true;
|
||||||
|
});
|
||||||
|
const repository = new TypeOrmNotificationRepository({
|
||||||
|
update,
|
||||||
|
} as unknown as Repository<NotificationOrmEntity>);
|
||||||
|
const service = new NotificationService(repository);
|
||||||
|
await service.markAsRead(id, other);
|
||||||
|
expect(row.read).toBe(false);
|
||||||
|
expect(update).toHaveBeenLastCalledWith(
|
||||||
|
{ id, user_id: other },
|
||||||
|
expect.objectContaining({ read: true })
|
||||||
|
);
|
||||||
|
await service.markAsRead(id, owner);
|
||||||
|
expect(row.read).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -4,8 +4,8 @@
|
|||||||
* Handles creating and sending notifications to users
|
* Handles creating and sending notifications to users
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Injectable, Logger, Inject } from '@nestjs/common';
|
import { Injectable, Logger, Inject, BadRequestException } from '@nestjs/common';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4, validate as isUuid } from 'uuid';
|
||||||
import {
|
import {
|
||||||
Notification,
|
Notification,
|
||||||
NotificationType,
|
NotificationType,
|
||||||
@ -109,8 +109,11 @@ export class NotificationService {
|
|||||||
/**
|
/**
|
||||||
* Mark notification as read
|
* Mark notification as read
|
||||||
*/
|
*/
|
||||||
async markAsRead(id: string): Promise<void> {
|
async markAsRead(id: string, userId: string): Promise<void> {
|
||||||
await this.notificationRepository.markAsRead(id);
|
if (typeof id !== 'string' || !isUuid(id) || typeof userId !== 'string' || !isUuid(userId)) {
|
||||||
|
throw new BadRequestException('Invalid notification or user ID');
|
||||||
|
}
|
||||||
|
await this.notificationRepository.markAsRead(id, userId);
|
||||||
this.logger.log(`Notification marked as read: ${id}`);
|
this.logger.log(`Notification marked as read: ${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,56 @@
|
|||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { RawBodyRequest } from '@nestjs/common';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import { SubscriptionService } from './subscription.service';
|
||||||
|
import { SubscriptionsController } from '../controllers/subscriptions.controller';
|
||||||
|
import { Subscription } from '@domain/entities/subscription.entity';
|
||||||
|
import { SubscriptionPlan } from '@domain/value-objects/subscription-plan.vo';
|
||||||
|
import { SubscriptionRepository } from '@domain/ports/out/subscription.repository';
|
||||||
|
import { LicenseRepository } from '@domain/ports/out/license.repository';
|
||||||
|
import { OrganizationRepository } from '@domain/ports/out/organization.repository';
|
||||||
|
import { UserRepository } from '@domain/ports/out/user.repository';
|
||||||
|
import { StripePort } from '@domain/ports/out/stripe.port';
|
||||||
|
|
||||||
|
describe('Stripe subscription deletion', () => {
|
||||||
|
it('persists cancellation with excess seats and accepts a duplicate event', async () => {
|
||||||
|
let saved = Subscription.create({
|
||||||
|
id: 'sub',
|
||||||
|
organizationId: 'org',
|
||||||
|
plan: SubscriptionPlan.gold(),
|
||||||
|
});
|
||||||
|
const save = jest.fn(async (value: Subscription) => {
|
||||||
|
saved = value;
|
||||||
|
});
|
||||||
|
const count = jest.fn(async () => 10);
|
||||||
|
const service = new SubscriptionService(
|
||||||
|
{ findByStripeSubscriptionId: async () => saved, save } as unknown as SubscriptionRepository,
|
||||||
|
{ countActiveBySubscriptionIdExcludingAdmins: count } as unknown as LicenseRepository,
|
||||||
|
{ findById: async () => null } as unknown as OrganizationRepository,
|
||||||
|
{} as UserRepository,
|
||||||
|
{
|
||||||
|
constructWebhookEvent: async () => ({
|
||||||
|
type: 'customer.subscription.deleted',
|
||||||
|
data: { object: { id: 'stripe-sub' } },
|
||||||
|
}),
|
||||||
|
} as unknown as StripePort,
|
||||||
|
new ConfigService()
|
||||||
|
);
|
||||||
|
await service.handleStripeWebhook(Buffer.from('signed fixture'), 'signature');
|
||||||
|
await service.handleStripeWebhook(Buffer.from('signed fixture'), 'signature');
|
||||||
|
expect(saved.plan.value).toBe('BRONZE');
|
||||||
|
expect(saved.status.value).toBe('CANCELED');
|
||||||
|
expect(save).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not acknowledge processing failures as successful delivery', async () => {
|
||||||
|
const handleStripeWebhook = jest.fn().mockRejectedValue(new Error('storage unavailable'));
|
||||||
|
const controller = new SubscriptionsController(
|
||||||
|
{ handleStripeWebhook } as unknown as SubscriptionService,
|
||||||
|
{} as OrganizationRepository
|
||||||
|
);
|
||||||
|
const req = { rawBody: Buffer.from('fixture') } as RawBodyRequest<Request>;
|
||||||
|
await expect(controller.handleWebhook('signature', req)).rejects.toMatchObject({ status: 500 });
|
||||||
|
handleStripeWebhook.mockResolvedValue(undefined);
|
||||||
|
await expect(controller.handleWebhook('signature', req)).resolves.toEqual({ received: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -608,12 +608,7 @@ export class SubscriptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Downgrade to FREE plan - count only non-ADMIN licenses
|
// Downgrade to FREE plan - count only non-ADMIN licenses
|
||||||
const canceledSubscription = subscription
|
const canceledSubscription = subscription.cancel();
|
||||||
.updatePlan(
|
|
||||||
SubscriptionPlan.bronze(),
|
|
||||||
await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id)
|
|
||||||
)
|
|
||||||
.updateStatus(SubscriptionStatus.canceled());
|
|
||||||
|
|
||||||
await this.subscriptionRepository.save(canceledSubscription);
|
await this.subscriptionRepository.save(canceledSubscription);
|
||||||
|
|
||||||
|
|||||||
@ -357,6 +357,18 @@ describe('Subscription Entity', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('cancel', () => {
|
describe('cancel', () => {
|
||||||
|
it('removes paid entitlements even when inactive, and remains idempotent', () => {
|
||||||
|
const paid = Subscription.create({
|
||||||
|
id: 'sub-paid',
|
||||||
|
organizationId: 'org-1',
|
||||||
|
plan: SubscriptionPlan.gold(),
|
||||||
|
});
|
||||||
|
const inactive = paid.updateStatus(SubscriptionStatus.canceled());
|
||||||
|
const result = inactive.cancel().cancel();
|
||||||
|
expect(result.plan.value).toBe('BRONZE');
|
||||||
|
expect(result.status.value).toBe('CANCELED');
|
||||||
|
expect(paid.plan.value).toBe('GOLD');
|
||||||
|
});
|
||||||
it('should cancel the subscription immediately', () => {
|
it('should cancel the subscription immediately', () => {
|
||||||
const subscription = createValidSubscription();
|
const subscription = createValidSubscription();
|
||||||
const updated = subscription.cancel();
|
const updated = subscription.cancel();
|
||||||
|
|||||||
@ -345,6 +345,7 @@ export class Subscription {
|
|||||||
return new Subscription({
|
return new Subscription({
|
||||||
...this.props,
|
...this.props,
|
||||||
status: SubscriptionStatus.canceled(),
|
status: SubscriptionStatus.canceled(),
|
||||||
|
plan: SubscriptionPlan.bronze(),
|
||||||
cancelAtPeriodEnd: false,
|
cancelAtPeriodEnd: false,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -60,7 +60,7 @@ export interface NotificationRepository {
|
|||||||
/**
|
/**
|
||||||
* Mark a notification as read
|
* Mark a notification as read
|
||||||
*/
|
*/
|
||||||
markAsRead(id: string): Promise<void>;
|
markAsRead(id: string, userId: string): Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark all notifications as read for a user
|
* Mark all notifications as read for a user
|
||||||
|
|||||||
138
apps/backend/src/infrastructure/email/email.adapter.spec.ts
Normal file
138
apps/backend/src/infrastructure/email/email.adapter.spec.ts
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import * as nodemailer from 'nodemailer';
|
||||||
|
import SMTPTransport from 'nodemailer/lib/smtp-transport';
|
||||||
|
import { createServer, Server, Socket } from 'net';
|
||||||
|
import { EmailAdapter } from './email.adapter';
|
||||||
|
import { EmailTemplates } from './templates/email-templates';
|
||||||
|
|
||||||
|
jest.mock('nodemailer', () => ({ createTransport: jest.fn() }));
|
||||||
|
|
||||||
|
const configuration = (values: Record<string, unknown>) =>
|
||||||
|
({
|
||||||
|
get: (key: string, fallback?: unknown) => values[key] ?? fallback,
|
||||||
|
}) as ConfigService;
|
||||||
|
|
||||||
|
describe('SMTP transport security', () => {
|
||||||
|
const verify = jest.fn();
|
||||||
|
const sendMail = jest.fn();
|
||||||
|
const options = (environment: string, secure = false) => {
|
||||||
|
const adapter = new EmailAdapter(
|
||||||
|
configuration({
|
||||||
|
NODE_ENV: environment,
|
||||||
|
SMTP_PORT: secure ? 465 : 587,
|
||||||
|
SMTP_SECURE: secure,
|
||||||
|
SMTP_USER: 'test-user',
|
||||||
|
SMTP_PASS: 'test-only-password',
|
||||||
|
}),
|
||||||
|
{} as EmailTemplates
|
||||||
|
);
|
||||||
|
adapter['buildTransporter']('127.0.0.1', 'smtp.example.org');
|
||||||
|
return {
|
||||||
|
adapter,
|
||||||
|
config: jest
|
||||||
|
.mocked(nodemailer.createTransport)
|
||||||
|
.mock.calls.at(-1)![0] as SMTPTransport.Options,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
jest
|
||||||
|
.mocked(nodemailer.createTransport)
|
||||||
|
.mockReturnValue({ verify, sendMail } as unknown as nodemailer.Transporter);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires production TLS and validates the original hostname after IP resolution', () => {
|
||||||
|
const { config } = options('production');
|
||||||
|
expect(config.requireTLS).toBe(true);
|
||||||
|
expect(config.tls).toMatchObject({ rejectUnauthorized: true, servername: 'smtp.example.org' });
|
||||||
|
expect(config.host).toBe('127.0.0.1');
|
||||||
|
expect(config.secure).toBe(false);
|
||||||
|
});
|
||||||
|
it('preserves implicit TLS and local development plaintext support', () => {
|
||||||
|
expect(options('production', true).config.secure).toBe(true);
|
||||||
|
expect(options('development').config.requireTLS).toBe(false);
|
||||||
|
expect(options('development').config.tls?.rejectUnauthorized).toBe(true);
|
||||||
|
});
|
||||||
|
it('propagates secure delivery failures', async () => {
|
||||||
|
const { adapter } = options('production');
|
||||||
|
sendMail.mockRejectedValue(new Error('certificate verification failed'));
|
||||||
|
await expect(
|
||||||
|
adapter.send({ to: 'test@example.org', subject: 'Test', text: 'Test' })
|
||||||
|
).rejects.toThrow('certificate verification failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SMTP STARTTLS downgrade regression', () => {
|
||||||
|
let server: Server;
|
||||||
|
const sockets = new Set<Socket>();
|
||||||
|
const commands: string[] = [];
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = createServer(socket => {
|
||||||
|
sockets.add(socket);
|
||||||
|
socket.on('close', () => sockets.delete(socket));
|
||||||
|
socket.write('220 localhost test SMTP\r\n');
|
||||||
|
let pending = '';
|
||||||
|
socket.on('data', chunk => {
|
||||||
|
pending += chunk.toString();
|
||||||
|
let end: number;
|
||||||
|
while ((end = pending.indexOf('\r\n')) >= 0) {
|
||||||
|
const command = pending.slice(0, end);
|
||||||
|
pending = pending.slice(end + 2);
|
||||||
|
commands.push(command.split(' ')[0]);
|
||||||
|
if (/^EHLO/.test(command)) socket.write('250-localhost\r\n250 AUTH PLAIN\r\n');
|
||||||
|
else if (/^STARTTLS/.test(command)) socket.write('454 TLS unavailable\r\n');
|
||||||
|
else if (/^AUTH/.test(command)) socket.write('235 Authentication successful\r\n');
|
||||||
|
else socket.write('250 OK\r\n');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(0, '127.0.0.1', resolve);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const socket of sockets) socket.destroy();
|
||||||
|
if (server?.listening) await new Promise<void>(resolve => server.close(() => resolve()));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a downgrade before sending credentials, while the local test control can authenticate', async () => {
|
||||||
|
const actual = jest.requireActual<typeof nodemailer>('nodemailer');
|
||||||
|
jest
|
||||||
|
.mocked(nodemailer.createTransport)
|
||||||
|
.mockReturnValue({ verify: jest.fn() } as unknown as nodemailer.Transporter);
|
||||||
|
const adapter = new EmailAdapter(
|
||||||
|
configuration({
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
SMTP_USER: 'test-user',
|
||||||
|
SMTP_PASS: 'test-password',
|
||||||
|
}),
|
||||||
|
{} as EmailTemplates
|
||||||
|
);
|
||||||
|
adapter['buildTransporter']('127.0.0.1', 'localhost');
|
||||||
|
const config = jest
|
||||||
|
.mocked(nodemailer.createTransport)
|
||||||
|
.mock.calls.at(-1)![0] as SMTPTransport.Options;
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === 'string') throw new Error('Missing test server');
|
||||||
|
const transport = actual.createTransport({ ...config, port: address.port });
|
||||||
|
try {
|
||||||
|
await expect(transport.verify()).rejects.toThrow();
|
||||||
|
expect(commands).toContain('STARTTLS');
|
||||||
|
expect(commands).not.toContain('AUTH');
|
||||||
|
} finally {
|
||||||
|
transport.close();
|
||||||
|
}
|
||||||
|
const localControl = actual.createTransport({
|
||||||
|
...config,
|
||||||
|
port: address.port,
|
||||||
|
requireTLS: false,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await expect(localControl.verify()).resolves.toBe(true);
|
||||||
|
expect(commands).toContain('AUTH');
|
||||||
|
} finally {
|
||||||
|
localControl.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -124,9 +124,10 @@ export class EmailAdapter implements EmailPort, OnModuleInit {
|
|||||||
host: actualHost,
|
host: actualHost,
|
||||||
port,
|
port,
|
||||||
secure,
|
secure,
|
||||||
|
requireTLS: this.configService.get<string>('NODE_ENV') === 'production',
|
||||||
auth: { user, pass },
|
auth: { user, pass },
|
||||||
tls: {
|
tls: {
|
||||||
rejectUnauthorized: false,
|
rejectUnauthorized: true,
|
||||||
servername: serverName,
|
servername: serverName,
|
||||||
},
|
},
|
||||||
connectionTimeout: 15000,
|
connectionTimeout: 15000,
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { config } from 'dotenv';
|
import { config } from 'dotenv';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
import { databaseTlsOptions } from './database-tls';
|
||||||
|
|
||||||
// Load environment variables
|
// Load environment variables
|
||||||
config();
|
config();
|
||||||
@ -23,5 +24,9 @@ export const AppDataSource = new DataSource({
|
|||||||
subscribers: [],
|
subscribers: [],
|
||||||
synchronize: false, // Never use in production
|
synchronize: false, // Never use in production
|
||||||
logging: process.env.NODE_ENV === 'development',
|
logging: process.env.NODE_ENV === 'development',
|
||||||
ssl: process.env.DATABASE_SSL === 'true' ? { rejectUnauthorized: false } : false,
|
ssl: databaseTlsOptions(
|
||||||
|
process.env.DATABASE_SSL,
|
||||||
|
process.env.DATABASE_SSL_CA,
|
||||||
|
process.env.DATABASE_HOST
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -0,0 +1,89 @@
|
|||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { resolve, join } from 'path';
|
||||||
|
import { runInNewContext } from 'vm';
|
||||||
|
import { databaseTlsOptions } from './database-tls';
|
||||||
|
|
||||||
|
describe('database bootstrap TLS wiring', () => {
|
||||||
|
it.each([true, false])(
|
||||||
|
'applies identical TLS settings to readiness and migrations (packaged=%s)',
|
||||||
|
async packaged => {
|
||||||
|
const backendRoot = resolve(__dirname, '../../../..');
|
||||||
|
const applicationRoot = packaged ? '/app' : '/workspace/apps/backend';
|
||||||
|
const scriptDirectory = packaged ? '/app' : `${applicationRoot}/scripts/setup`;
|
||||||
|
const clients: Record<string, unknown>[] = [];
|
||||||
|
const sources: Record<string, unknown>[] = [];
|
||||||
|
const helperPaths: string[] = [];
|
||||||
|
const env = {
|
||||||
|
DATABASE_HOST: '10.10.1.20',
|
||||||
|
DATABASE_SSL: 'true',
|
||||||
|
DATABASE_SSL_CA: 'test-public-ca',
|
||||||
|
};
|
||||||
|
const processExit = jest.fn();
|
||||||
|
class Client {
|
||||||
|
constructor(config: Record<string, unknown>) {
|
||||||
|
clients.push(config);
|
||||||
|
}
|
||||||
|
async connect() {}
|
||||||
|
async end() {}
|
||||||
|
}
|
||||||
|
class DataSource {
|
||||||
|
constructor(config: Record<string, unknown>) {
|
||||||
|
sources.push(config);
|
||||||
|
}
|
||||||
|
async initialize() {}
|
||||||
|
async runMigrations() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
async destroy() {}
|
||||||
|
}
|
||||||
|
const mockRequire = (name: string): unknown => {
|
||||||
|
if (name === 'pg') return { Client };
|
||||||
|
if (name === 'typeorm') return { DataSource };
|
||||||
|
if (name === 'path') return { join, resolve };
|
||||||
|
if (name === 'fs') return { existsSync: () => packaged };
|
||||||
|
if (name === 'child_process') return { spawn: jest.fn() };
|
||||||
|
helperPaths.push(name);
|
||||||
|
expect(name).toBe(
|
||||||
|
`${applicationRoot}/dist/infrastructure/persistence/typeorm/database-tls`
|
||||||
|
);
|
||||||
|
return { databaseTlsOptions };
|
||||||
|
};
|
||||||
|
const module = {
|
||||||
|
exports: {} as {
|
||||||
|
waitForPostgres: () => Promise<unknown>;
|
||||||
|
runMigrations: () => Promise<unknown>;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const context = {
|
||||||
|
require: mockRequire,
|
||||||
|
module,
|
||||||
|
__dirname: scriptDirectory,
|
||||||
|
process: { env, exit: processExit },
|
||||||
|
console: { log: jest.fn(), error: jest.fn() },
|
||||||
|
};
|
||||||
|
runInNewContext(readFileSync(join(backendRoot, 'scripts/setup/startup.js'), 'utf8'), context);
|
||||||
|
await module.exports.waitForPostgres();
|
||||||
|
await module.exports.runMigrations();
|
||||||
|
await runInNewContext(
|
||||||
|
readFileSync(join(backendRoot, 'scripts/setup/run-migrations.js'), 'utf8'),
|
||||||
|
{ ...context, module: { exports: {} } }
|
||||||
|
);
|
||||||
|
expect(helperPaths).toHaveLength(2);
|
||||||
|
expect(clients).toHaveLength(1);
|
||||||
|
expect(sources).toHaveLength(2);
|
||||||
|
for (const config of [...clients, ...sources]) {
|
||||||
|
expect(config.ssl).toEqual({
|
||||||
|
rejectUnauthorized: true,
|
||||||
|
host: env.DATABASE_HOST,
|
||||||
|
ca: env.DATABASE_SSL_CA,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const config of sources) {
|
||||||
|
expect(config.migrations).toEqual([
|
||||||
|
`${applicationRoot}/dist/infrastructure/persistence/typeorm/migrations/*.js`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
expect(processExit).not.toHaveBeenCalledWith(1);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -0,0 +1,109 @@
|
|||||||
|
import { databaseTlsOptions } from './database-tls';
|
||||||
|
import { createServer, connect, TLSSocket, Server } from 'tls';
|
||||||
|
import { connect as connectSocket } from 'net';
|
||||||
|
import { execFileSync } from 'child_process';
|
||||||
|
import { mkdtempSync, readFileSync, unlinkSync, rmdirSync } from 'fs';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
describe('database TLS configuration', () => {
|
||||||
|
it.each([undefined, false, 'false', 'FALSE'])(
|
||||||
|
'preserves explicit local non-TLS setting %j',
|
||||||
|
enabled => {
|
||||||
|
expect(databaseTlsOptions(enabled)).toBe(false);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
it.each([true, 'true', 'TRUE'])('requires certificate and host verification for %j', enabled => {
|
||||||
|
expect(databaseTlsOptions(enabled, undefined, '10.10.1.20')).toEqual({
|
||||||
|
rejectUnauthorized: true,
|
||||||
|
host: '10.10.1.20',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('rejects invalid flags and empty custom trust', () => {
|
||||||
|
expect(() => databaseTlsOptions('typo')).toThrow();
|
||||||
|
expect(() => databaseTlsOptions(true, ' ')).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('database TLS certificate and IP identity', () => {
|
||||||
|
let directory: string;
|
||||||
|
let cert: string;
|
||||||
|
let server: Server;
|
||||||
|
const sockets = new Set<TLSSocket>();
|
||||||
|
beforeAll(async () => {
|
||||||
|
directory = mkdtempSync(join(tmpdir(), 'xpeditis-db-tls-test-'));
|
||||||
|
execFileSync(
|
||||||
|
'openssl',
|
||||||
|
[
|
||||||
|
'req',
|
||||||
|
'-new',
|
||||||
|
'-x509',
|
||||||
|
'-nodes',
|
||||||
|
'-days',
|
||||||
|
'1',
|
||||||
|
'-newkey',
|
||||||
|
'rsa:2048',
|
||||||
|
'-keyout',
|
||||||
|
join(directory, 'key.pem'),
|
||||||
|
'-out',
|
||||||
|
join(directory, 'cert.pem'),
|
||||||
|
'-subj',
|
||||||
|
'/CN=localhost',
|
||||||
|
'-addext',
|
||||||
|
'subjectAltName=IP:127.0.0.1,DNS:localhost',
|
||||||
|
],
|
||||||
|
{ stdio: 'ignore' }
|
||||||
|
);
|
||||||
|
cert = readFileSync(join(directory, 'cert.pem'), 'utf8');
|
||||||
|
server = createServer({ key: readFileSync(join(directory, 'key.pem')), cert }, socket => {
|
||||||
|
sockets.add(socket);
|
||||||
|
socket.on('close', () => sockets.delete(socket));
|
||||||
|
socket.end();
|
||||||
|
});
|
||||||
|
server.on('tlsClientError', () => undefined);
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(0, '127.0.0.1', resolve);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const socket of sockets) socket.destroy();
|
||||||
|
if (server?.listening) await new Promise<void>(resolve => server.close(() => resolve()));
|
||||||
|
if (directory) {
|
||||||
|
unlinkSync(join(directory, 'key.pem'));
|
||||||
|
unlinkSync(join(directory, 'cert.pem'));
|
||||||
|
rmdirSync(directory);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mimic pg: TLS wraps an existing socket, with no SNI when DATABASE_HOST is an IP.
|
||||||
|
function handshake(ca: string | undefined, host: string): Promise<boolean> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === 'string') return reject(new Error('Missing server'));
|
||||||
|
const socket = connectSocket(address.port, '127.0.0.1');
|
||||||
|
const options = databaseTlsOptions(true, ca, host);
|
||||||
|
if (!options) return reject(new Error('TLS must be enabled'));
|
||||||
|
const client = connect({ socket, ...options }, () => {
|
||||||
|
client.end();
|
||||||
|
resolve(client.authorized);
|
||||||
|
});
|
||||||
|
client.once('error', error => {
|
||||||
|
client.destroy();
|
||||||
|
socket.destroy();
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
it('accepts a trusted certificate with the configured IP SAN', async () => {
|
||||||
|
await expect(handshake(cert, '127.0.0.1')).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
it('rejects an untrusted certificate', async () => {
|
||||||
|
await expect(handshake(undefined, '127.0.0.1')).rejects.toThrow();
|
||||||
|
});
|
||||||
|
it('rejects a trusted certificate for the wrong database IP', async () => {
|
||||||
|
await expect(handshake(cert, '127.0.0.2')).rejects.toMatchObject({
|
||||||
|
code: 'ERR_TLS_CERT_ALTNAME_INVALID',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
/** Shared by the API, migration CLI and container startup clients. */
|
||||||
|
export function databaseTlsOptions(
|
||||||
|
enabled: boolean | string | undefined,
|
||||||
|
certificateAuthority?: string,
|
||||||
|
host = 'localhost'
|
||||||
|
): false | { rejectUnauthorized: true; host: string; ca?: string } {
|
||||||
|
const flag = typeof enabled === 'string' ? enabled.toLowerCase() : enabled;
|
||||||
|
if (flag === undefined || flag === false || flag === 'false') return false;
|
||||||
|
if (flag !== true && flag !== 'true') {
|
||||||
|
throw new Error('DATABASE_SSL must be true or false');
|
||||||
|
}
|
||||||
|
if (certificateAuthority !== undefined && !certificateAuthority.trim()) {
|
||||||
|
throw new Error('DATABASE_SSL_CA must contain a PEM certificate when supplied');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
rejectUnauthorized: true,
|
||||||
|
host,
|
||||||
|
...(certificateAuthority ? { ca: certificateAuthority } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -150,11 +150,14 @@ export class TypeOrmNotificationRepository implements NotificationRepository {
|
|||||||
return ormEntities.map(e => this.toDomain(e));
|
return ormEntities.map(e => this.toDomain(e));
|
||||||
}
|
}
|
||||||
|
|
||||||
async markAsRead(id: string): Promise<void> {
|
async markAsRead(id: string, userId: string): Promise<void> {
|
||||||
await this.ormRepository.update(id, {
|
await this.ormRepository.update(
|
||||||
read: true,
|
{ id, user_id: userId },
|
||||||
read_at: new Date(),
|
{
|
||||||
});
|
read: true,
|
||||||
|
read_at: new Date(),
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async markAllAsReadForUser(userId: string): Promise<void> {
|
async markAllAsReadForUser(userId: string): Promise<void> {
|
||||||
|
|||||||
@ -94,6 +94,14 @@ const makeBooking = (overrides: Partial<Booking> = {}): Booking => ({
|
|||||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('exportToCSV', () => {
|
describe('exportToCSV', () => {
|
||||||
|
it('neutralizes formulas in formatted values and header labels', () => {
|
||||||
|
exportToCSV(
|
||||||
|
[makeBooking()],
|
||||||
|
[{ key: 'bookingNumber', label: '=1+1', formatter: () => '\t=2+2' }]
|
||||||
|
);
|
||||||
|
expect(capturedBlobParts.join('')).toContain('"\'=1+1"');
|
||||||
|
expect(capturedBlobParts.join('')).toContain('"\'\t=2+2"');
|
||||||
|
});
|
||||||
it('calls saveAs once', () => {
|
it('calls saveAs once', () => {
|
||||||
exportToCSV([makeBooking()]);
|
exportToCSV([makeBooking()]);
|
||||||
expect(mockSaveAs).toHaveBeenCalledTimes(1);
|
expect(mockSaveAs).toHaveBeenCalledTimes(1);
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { csvCell } from '@/utils/csv-cell';
|
||||||
import { useTranslations, useLocale } from 'next-intl';
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
import { Download, FileSpreadsheet, FileText, ChevronDown, Lock } from 'lucide-react';
|
import { Download, FileSpreadsheet, FileText, ChevronDown, Lock } from 'lucide-react';
|
||||||
import { useSubscription } from '@/lib/context/subscription-context';
|
import { useSubscription } from '@/lib/context/subscription-context';
|
||||||
@ -63,14 +64,14 @@ export default function ExportButton<T extends Record<string, any>>({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const generateCSV = (): string => {
|
const generateCSV = (): string => {
|
||||||
const headers = columns.map(col => `"${col.label.replace(/"/g, '""')}"`).join(';');
|
const headers = columns.map(col => csvCell(col.label)).join(';');
|
||||||
|
|
||||||
const rows = data.map(row => {
|
const rows = data.map(row => {
|
||||||
return columns
|
return columns
|
||||||
.map(col => {
|
.map(col => {
|
||||||
const value = getNestedValue(row, col.key as string);
|
const value = getNestedValue(row, col.key as string);
|
||||||
const formattedValue = col.format ? col.format(value, row) : formatValue(value);
|
const formattedValue = col.format ? col.format(value, row) : formatValue(value);
|
||||||
return `"${formattedValue.replace(/"/g, '""')}"`;
|
return csvCell(formattedValue);
|
||||||
})
|
})
|
||||||
.join(';');
|
.join(';');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -66,7 +66,6 @@ export interface CsvBookingResponse {
|
|||||||
url: string;
|
url: string;
|
||||||
}>;
|
}>;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
confirmationToken: string;
|
|
||||||
emailSentAt?: string;
|
emailSentAt?: string;
|
||||||
acceptedAt?: string;
|
acceptedAt?: string;
|
||||||
rejectedAt?: string;
|
rejectedAt?: string;
|
||||||
|
|||||||
53
apps/frontend/src/lib/context/auth-context.test.tsx
Normal file
53
apps/frontend/src/lib/context/auth-context.test.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { act, renderHook } from '@testing-library/react';
|
||||||
|
import { AuthProvider, useAuth } from './auth-context';
|
||||||
|
import { getCurrentUser, login } from '../api/auth';
|
||||||
|
|
||||||
|
const mockPush = jest.fn();
|
||||||
|
jest.mock('next/navigation', () => ({ useRouter: () => ({ push: mockPush }) }));
|
||||||
|
jest.mock('../api/auth', () => ({
|
||||||
|
login: jest.fn(),
|
||||||
|
getCurrentUser: jest.fn(),
|
||||||
|
register: jest.fn(),
|
||||||
|
logout: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mock('../api/client', () => ({ hasSession: () => false, clearAuthTokens: jest.fn() }));
|
||||||
|
|
||||||
|
describe('authenticated navigation', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
jest
|
||||||
|
.mocked(getCurrentUser)
|
||||||
|
.mockResolvedValue({ id: 'test-user' } as Awaited<ReturnType<typeof getCurrentUser>>);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['javascript:alert(1)', '//example.org', '/\\example.org', '/\n/example.org'])(
|
||||||
|
'does not navigate to attacker destination %j',
|
||||||
|
async destination => {
|
||||||
|
const { result } = renderHook(useAuth, { wrapper: AuthProvider });
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.login('user@example.org', 'password', destination);
|
||||||
|
});
|
||||||
|
expect(login).toHaveBeenCalledWith({
|
||||||
|
email: 'user@example.org',
|
||||||
|
password: 'password',
|
||||||
|
rememberMe: false,
|
||||||
|
});
|
||||||
|
expect(mockPush).toHaveBeenCalledWith('/dashboard');
|
||||||
|
expect(result.current.isAuthenticated).toBe(true);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it('preserves localized navigation after successful login', async () => {
|
||||||
|
const { result } = renderHook(useAuth, { wrapper: AuthProvider });
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.login(
|
||||||
|
'user@example.org',
|
||||||
|
'password',
|
||||||
|
'/fr/dashboard?tab=1#bookings',
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(mockPush).toHaveBeenCalledWith('/fr/dashboard?tab=1#bookings');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -16,6 +16,7 @@ import {
|
|||||||
} from '../api/auth';
|
} from '../api/auth';
|
||||||
import { hasSession, clearAuthTokens } from '../api/client';
|
import { hasSession, clearAuthTokens } from '../api/client';
|
||||||
import type { UserPayload } from '@/types/api';
|
import type { UserPayload } from '@/types/api';
|
||||||
|
import { safeLoginRedirect } from '../safe-login-redirect';
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
user: UserPayload | null;
|
user: UserPayload | null;
|
||||||
@ -107,7 +108,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
// Fetch complete user profile after login (session lives in httpOnly cookies)
|
// Fetch complete user profile after login (session lives in httpOnly cookies)
|
||||||
const currentUser = await getCurrentUser();
|
const currentUser = await getCurrentUser();
|
||||||
setUser(currentUser);
|
setUser(currentUser);
|
||||||
router.push(redirectTo);
|
router.push(safeLoginRedirect(redirectTo));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
23
apps/frontend/src/lib/safe-login-redirect.test.ts
Normal file
23
apps/frontend/src/lib/safe-login-redirect.test.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { safeLoginRedirect } from './safe-login-redirect';
|
||||||
|
|
||||||
|
describe('safeLoginRedirect', () => {
|
||||||
|
it.each([
|
||||||
|
'javascript:alert(1)',
|
||||||
|
'JaVaScRiPt:alert(1)',
|
||||||
|
'data:text/html,test',
|
||||||
|
'https://example.org',
|
||||||
|
'//example.org',
|
||||||
|
'/\\example.org',
|
||||||
|
'/\n/example.org',
|
||||||
|
'/\t/example.org',
|
||||||
|
' /dashboard',
|
||||||
|
'',
|
||||||
|
])('rejects unsafe navigation %j', destination => {
|
||||||
|
expect(safeLoginRedirect(destination)).toBe('/dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['/dashboard', '/fr/dashboard?tab=bookings#recent', '/booking/123', '/search?q=a%20b'])(
|
||||||
|
'preserves internal navigation %s',
|
||||||
|
destination => expect(safeLoginRedirect(destination)).toBe(destination)
|
||||||
|
);
|
||||||
|
});
|
||||||
12
apps/frontend/src/lib/safe-login-redirect.ts
Normal file
12
apps/frontend/src/lib/safe-login-redirect.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
/** Only application paths may be used after authentication. */
|
||||||
|
export function safeLoginRedirect(destination: string): string {
|
||||||
|
if (
|
||||||
|
!destination.startsWith('/') ||
|
||||||
|
destination.startsWith('//') ||
|
||||||
|
/[\\\u0000-\u0020\u007f]/.test(destination)
|
||||||
|
) {
|
||||||
|
return '/dashboard';
|
||||||
|
}
|
||||||
|
|
||||||
|
return destination;
|
||||||
|
}
|
||||||
15
apps/frontend/src/utils/csv-cell.test.ts
Normal file
15
apps/frontend/src/utils/csv-cell.test.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { csvCell } from './csv-cell';
|
||||||
|
|
||||||
|
describe('CSV spreadsheet safety', () => {
|
||||||
|
it.each(['=1+1', '+SUM(1)', '-1+1', '@SUM(1)', ' =1', '\t=1', '\r=1', '\n=1'])(
|
||||||
|
'forces formula-like values to text: %j',
|
||||||
|
value => {
|
||||||
|
expect(csvCell(value)).toBe(`"'${value}"`);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
it('preserves ordinary text, numeric zero and CSV escaping', () => {
|
||||||
|
expect(csvCell('Paris; "France"')).toBe('"Paris; ""France"""');
|
||||||
|
expect(csvCell(0)).toBe('"0"');
|
||||||
|
expect(csvCell(null)).toBe('""');
|
||||||
|
});
|
||||||
|
});
|
||||||
7
apps/frontend/src/utils/csv-cell.ts
Normal file
7
apps/frontend/src/utils/csv-cell.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
/** Quote a CSV cell and force spreadsheet formula prefixes to be treated as text. */
|
||||||
|
export function csvCell(value: unknown): string {
|
||||||
|
const text = String(value ?? '');
|
||||||
|
const safe =
|
||||||
|
/^[\s\u0000-\u001f]*[=+\-@]/.test(text) || /^[\t\r\n]/.test(text) ? `'${text}` : text;
|
||||||
|
return `"${safe.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { csvCell } from './csv-cell';
|
||||||
/**
|
/**
|
||||||
* Client-side export utilities
|
* Client-side export utilities
|
||||||
*/
|
*/
|
||||||
@ -58,7 +59,7 @@ export function exportToCSV(
|
|||||||
filename: string = 'bookings-export.csv'
|
filename: string = 'bookings-export.csv'
|
||||||
): void {
|
): void {
|
||||||
// Create CSV header
|
// Create CSV header
|
||||||
const header = fields.map(f => f.label).join(',');
|
const header = fields.map(f => csvCell(f.label)).join(',');
|
||||||
|
|
||||||
// Create CSV rows
|
// Create CSV rows
|
||||||
const rows = data.map(booking => {
|
const rows = data.map(booking => {
|
||||||
@ -67,8 +68,7 @@ export function exportToCSV(
|
|||||||
const value = getNestedValue(booking, field.key);
|
const value = getNestedValue(booking, field.key);
|
||||||
const formatted = field.formatter ? field.formatter(value) : value;
|
const formatted = field.formatter ? field.formatter(value) : value;
|
||||||
// Escape quotes and wrap in quotes if contains comma
|
// Escape quotes and wrap in quotes if contains comma
|
||||||
const escaped = String(formatted || '').replace(/"/g, '""');
|
return csvCell(formatted);
|
||||||
return `"${escaped}"`;
|
|
||||||
})
|
})
|
||||||
.join(',');
|
.join(',');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -134,7 +134,7 @@ services:
|
|||||||
SMTP_HOST: smtp-relay.brevo.com
|
SMTP_HOST: smtp-relay.brevo.com
|
||||||
SMTP_PORT: 587
|
SMTP_PORT: 587
|
||||||
SMTP_USER: 9637ef001@smtp-brevo.com
|
SMTP_USER: 9637ef001@smtp-brevo.com
|
||||||
SMTP_PASS: xsmtpsib-8d965bda028cd63bed868a119f9e0330485204bf9f4e1f92a3a11c8e61000722-xUYUSrGGxhMqlUcu
|
SMTP_PASS: ${SMTP_PASS:?Set SMTP_PASS in the deployment environment}
|
||||||
SMTP_SECURE: "false"
|
SMTP_SECURE: "false"
|
||||||
SMTP_FROM: noreply@xpeditis.com
|
SMTP_FROM: noreply@xpeditis.com
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
61
docs/security/check-secu/REMEDIATION-2026-09-10.md
Normal file
61
docs/security/check-secu/REMEDIATION-2026-09-10.md
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
# Corrections supplémentaires — 10 septembre 2026
|
||||||
|
|
||||||
|
Branche `check_secu`. Les modifications antérieures sont conservées. Deux problèmes supplémentaires ont été traités successivement ; aucun commit, déploiement, accès à une base réelle ou envoi d’email réel n’a été effectué.
|
||||||
|
|
||||||
|
## 1. SMTP : certificat non vérifié et STARTTLS facultatif
|
||||||
|
|
||||||
|
**Résultat local : corrigé (`fixed`).**
|
||||||
|
|
||||||
|
L’unique transport Nodemailer désactivait `rejectUnauthorized`. La production et la préproduction utilisent le port 587 avec `SMTP_SECURE=false`, donc STARTTLS plutôt que TLS implicite. Sans `requireTLS`, un serveur ou intermédiaire refusant STARTTLS pouvait conduire à une authentification sans chiffrement. Les messages concernés comprennent les liens de réinitialisation et d’invitation.
|
||||||
|
|
||||||
|
Le correctif dans `apps/backend/src/infrastructure/email/email.adapter.ts` active la vérification des certificats et impose STARTTLS lorsque `NODE_ENV=production`. Le nom SMTP d’origine reste utilisé pour vérifier le certificat après résolution de l’adresse IP. Le TLS implicite et les serveurs SMTP locaux de développement sans TLS restent supportés ; cette exception de développement ne doit pas servir en production.
|
||||||
|
|
||||||
|
Preuve : `email.adapter.spec.ts` contient quatre tests. Un serveur SMTP éphémère sur loopback, qui n’annonce pas STARTTLS et refuse sa commande, est rejeté avant AUTH. Le contrôle local explicitement sans TLS peut s’authentifier sur ce même serveur simulé. Les options de vérification de certificat, le nom d’origine, le TLS implicite et la propagation d’un échec d’envoi sont vérifiés. Aucun email n’est transmis.
|
||||||
|
|
||||||
|
Limite : le rejet d’un certificat SMTP invalide est vérifié via les options de transport et une erreur d’envoi simulée, pas par une connexion au fournisseur réel. Une investigation indépendante et une revue du correctif n’ont retenu aucun contournement confirmé.
|
||||||
|
|
||||||
|
## 2. PostgreSQL : paramètres TLS incohérents entre clients
|
||||||
|
|
||||||
|
**Résultat local : corrigé (`fixed`). Déploiement conditionné à la configuration de confiance.**
|
||||||
|
|
||||||
|
L’API et le script de démarrage ignoraient `DATABASE_SSL`, tandis que la CLI TypeORM acceptait les certificats non authentifiés. La configuration de production exige pourtant `hostssl`. Outre l’absence de vérification d’identité côté CLI, cette incohérence pouvait empêcher le démarrage de l’API face à la configuration PostgreSQL fournie.
|
||||||
|
|
||||||
|
Une fonction commune `databaseTlsOptions` est désormais utilisée par :
|
||||||
|
|
||||||
|
- la configuration TypeORM de l’API dans `app.module.ts` ;
|
||||||
|
- la source de données de la CLI TypeORM ;
|
||||||
|
- les clients de disponibilité et de migration de `scripts/setup/startup.js` ;
|
||||||
|
- le script `scripts/setup/run-migrations.js` ;
|
||||||
|
- le client du script d’entrypoint historique, bien que l’image actuelle utilise `startup.js`.
|
||||||
|
|
||||||
|
Lorsque `DATABASE_SSL=true`, tous ces clients vérifient le certificat et l’identité de `DATABASE_HOST`, y compris une adresse IP. `DATABASE_SSL_CA` accepte le certificat public PEM de confiance pour le certificat auto-signé déjà généré par l’infrastructure. L’absence de CA spécifique conserve les autorités reconnues par Node ; elle n’entraîne jamais une désactivation de la vérification. Une valeur de drapeau invalide est refusée. Les chaînes majuscules/minuscules suivent le comportement de validation Joi. Le mode local explicitement sans TLS reste disponible.
|
||||||
|
|
||||||
|
Les chemins des scripts ont également été alignés sur la racine backend : copie Docker à `/app/startup.js` et copie du dépôt sous `scripts/setup`. Les modules compilés, entités et migrations sont ainsi résolus au même endroit. Le chargement de `startup.js` depuis un test n’établit aucune connexion et ne lance pas de migration.
|
||||||
|
|
||||||
|
Preuves :
|
||||||
|
|
||||||
|
- 11 tests dans `database-tls.spec.ts` : drapeaux de configuration et véritables handshakes TLS locaux avec certificat de test éphémère. Le certificat approuvé avec le SAN correspondant à l’IP est accepté ; un certificat non approuvé ou une IP incorrecte sont refusés.
|
||||||
|
- 2 tests dans `database-startup.spec.ts` : mêmes paramètres SSL transmis au client de disponibilité et aux deux scripts de migration, dans les dispositions Docker et dépôt. PostgreSQL et TypeORM sont simulés, aucune migration réelle n’est exécutée.
|
||||||
|
- Chargement du script avec le helper réellement compilé : réussi, sans connexion.
|
||||||
|
- Investigation et revue indépendantes : aucun contournement ou régression confirmé. Le reviewer était limité par le sandbox pour ses handshakes ; les handshakes du parent ont été exécutés avec autorisation sur loopback et ont réussi.
|
||||||
|
|
||||||
|
**Avant déploiement :** renseigner `DATABASE_SSL_CA` dans le Secret backend SOPS avec le certificat **public** de db-01 récupéré via un canal d’administration authentifié. Ne jamais copier sa clé privée. L’API et le Job de migration consomment ce même Secret. Le gabarit de secrets et le README de production décrivent cette préparation. Aucun certificat de production n’a été lu ou modifié. Ne pas déployer le nouveau client contre un certificat auto-signé sans avoir distribué cette confiance.
|
||||||
|
|
||||||
|
Les scripts ponctuels de maintenance hors des chemins de démarrage n’ont pas tous été harmonisés ; leur revue reste à effectuer avant une utilisation sur une base TLS.
|
||||||
|
|
||||||
|
## Vérifications finales
|
||||||
|
|
||||||
|
- `npm test -- --runInBand` (backend) : **451 tests réussis**, 5 tests déjà ignorés, 32 suites réussies et 1 ignorée. Les tests réseau utilisent uniquement des serveurs éphémères sur `127.0.0.1`.
|
||||||
|
- `npm run build` (backend) : réussi.
|
||||||
|
- ESLint sur les fichiers TypeScript concernés : réussi.
|
||||||
|
- Vérifications de syntaxe Node des deux scripts de démarrage/migration : réussies.
|
||||||
|
- Vérification de syntaxe de l’entrypoint shell : réussie.
|
||||||
|
- `git diff --check` : réussi.
|
||||||
|
|
||||||
|
Les essais ont détecté puis permis de corriger une configuration de test masquée par `NODE_ENV=test`, ainsi qu’un type TLS trop large pour les options TypeORM. Aucun contrôle de sécurité n’a été assoupli pour contourner ces échecs.
|
||||||
|
|
||||||
|
## Analyse des dépendances en attente
|
||||||
|
|
||||||
|
La tentative initiale de `npm audit --package-lock-only --omit=dev --json` n’a pas pu joindre le registre npm (`ENOTFOUND`). La demande d’accès réseau a ensuite été refusée par la validation automatique : la liste des dépendances et versions aurait été envoyée vers une destination externe non explicitement autorisée.
|
||||||
|
|
||||||
|
Une demande d’autorisation est en attente. Aucun contournement ni nouvel envoi n’a été effectué. Cette analyse ne transmettrait ni le code source ni les fichiers `.env`. L’audit exhaustif du dépôt et des dépendances n’est donc toujours pas déclaré terminé.
|
||||||
65
docs/security/check-secu/REMEDIATION.md
Normal file
65
docs/security/check-secu/REMEDIATION.md
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
# Suivi des corrections de sécurité — 9 septembre 2026
|
||||||
|
|
||||||
|
Branche : `check_secu`, issue de `preparation_prod`, base `8446f879b676b303fdb2891388f88ff7e43f5fea`.
|
||||||
|
|
||||||
|
Des correctifs ont été appliqués aux 14 constats du rapport initial. Ils sont locaux, non commités et non déployés. Cela ne constitue pas une attestation de sécurité complète du site.
|
||||||
|
|
||||||
|
## Audit approfondi : échec, couverture incomplète
|
||||||
|
|
||||||
|
Le rapport initial reste un audit statique partiel : 95 fichiers lus intégralement, plus des lectures ciblées. La tentative d’audit approfondi n’a renvoyé aucun manifeste de découverte réussi. Aucun nouveau scan réussi ni absence de vulnérabilités ne sont revendiqués.
|
||||||
|
|
||||||
|
Erreur retournée par le coordinateur :
|
||||||
|
|
||||||
|
> Deep Scan stopped after 3 consecutive unsuccessful discovery workers (limit: 3); last failure (transient_error): You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at 5:47 PM.
|
||||||
|
> This is a terminal failure of this logical Deep Scan; no successful discovery manifest was returned.
|
||||||
|
|
||||||
|
Le coordinateur impose l’arrêt de cette tentative. Les corrections ci-dessous reposent sur les constats déjà conservés et leur vérification locale, pas sur une prétendue couverture exhaustive. Aucun nouveau candidat issu de cette tentative n’a été retourné. Son identifiant n’a pas été fourni dans la réponse d’échec ; son contexte durable n’a donc pas pu être relu par identifiant.
|
||||||
|
|
||||||
|
## Corrections et preuves
|
||||||
|
|
||||||
|
| Constat | Modification | Vérification / limite |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Modification inter-organisations | Toute cible étrangère est refusée à un acteur non ADMIN. | Tests MANAGER majuscule/minuscule, USER, VIEWER ; mises à jour propres et ADMIN conservées ; 404 conservé. |
|
||||||
|
| XSS après connexion | Redirections limitées aux chemins internes ; protocoles, doubles slashs, antislashs et contrôles refusés. | Tests du helper et du contexte React, avec destinations malveillantes et navigation localisée légitime. |
|
||||||
|
| Authentification WebSocket | Réutilisation de JwtStrategy ; type access, expiration et compte courant contrôlés à la connexion, aux messages et avant émission. | Tests refresh, type inconnu, expiration, désactivation après connexion et connexion valide. L’émission sortante a aussi été revue statiquement. |
|
||||||
|
| Notifications d’autres utilisateurs | UUID validés dans le service ; prédicat ORM `{ id, user_id }` ; propagation de l’utilisateur REST et WS. | Objets, tableaux, chaînes invalides rejetés ; mise à jour du propriétaire conservée, autre destinataire exclu. Test du prédicat avec repository simulé, sans PostgreSQL réel. |
|
||||||
|
| Jeton transporteur divulgué | Retrait du jeton du DTO client, du mapper et du type frontend. | Test du mapper ; données métier conservées ; appels d’email transporteur conservés. Les anciens jetons déjà exposés ne sont pas invalidés par cette suppression. |
|
||||||
|
| Mutations VIEWER | Rôles ADMIN/MANAGER/USER requis pour création, paiements, modifications et documents CSV. | Test HTTP : VIEWER refusé en création et lecture personnelle conservée. Les autres routes ont été vérifiées par inspection des gardes. |
|
||||||
|
| Liste organisation sans rôle | Rôles ADMIN/MANAGER requis pour les listes et statistiques globales de l’organisation. | Test HTTP refusant la liste globale à USER. |
|
||||||
|
| Secrets dans les logs | Suppression du mot de passe temporaire et du lien d’invitation ; journalisation par ID ; erreurs d’email sans contenu secret ; suppression des traces brutes de réservation. | Tests de création de compte et d’invitation, y compris exception contenant un token ; lien transmis au service d’email conservé, sans envoi réel. |
|
||||||
|
| Anciennes sessions après changement de mot de passe | Tokens liés par HMAC au hash courant du mot de passe ; vérification pour access et refresh. | Anciennes sessions rejetées après changement ; nouvelle connexion/refresh et modification de profil légitimes préservés. Aucun hash de mot de passe n’est mis dans le JWT. |
|
||||||
|
| Téléversements mémoire | Limites Multer : 10 Mio/fichier, nombre de fichiers, champs, parties et taille des champs. | Test HTTP d’un fichier dépassant la limite : 413 avant service ; petit fichier accepté. Pas de test de charge ni mesure de mémoire en production. |
|
||||||
|
| Manager modifiant un ADMIN | Refus de toute modification d’un compte ADMIN par un acteur non ADMIN. | Tests refus sans mutation/persistance et modification d’un utilisateur ordinaire autorisée. |
|
||||||
|
| Clé SMTP littérale | Remplacement par une variable obligatoire de déploiement dans `docker-compose.full.yml`. | Vérification statique ; valeur non reproduite dans les rapports. Pas de validation fournisseur ni révocation effectuée. |
|
||||||
|
| Formules CSV | Encodeur commun aux deux exports CSV, appliqué aux valeurs formatées et aux en-têtes. | Tests des préfixes de formule, espaces/contrôles, échappement et zéro ; exports XLSX existants préservés par leurs tests. |
|
||||||
|
| Résiliation conservant un plan payant | Transition de résiliation vers Bronze indépendante des plafonds et du statut précédent ; erreur HTTP en cas d’échec du webhook. | Tests état inactif, événement répété, nombre excessif de licences et échec de traitement non acquitté comme succès. Signature Stripe reste vérifiée par l’adaptateur existant ; Stripe réel non appelé. |
|
||||||
|
|
||||||
|
## Stratégie et compatibilité
|
||||||
|
|
||||||
|
Les corrections sont placées aux frontières partagées : contexte d’authentification frontend, validation de session backend, service/repository de notifications, gardes HTTP, sérialisation des réponses et transition de domaine de résiliation. Une investigation indépendante avant modification et une revue indépendante du diff ont été effectuées. La revue n’a retenu aucun contournement ou régression confirmé ; elle reste statique.
|
||||||
|
|
||||||
|
La liaison des sessions utilise le hash déjà stocké et un HMAC avec le secret JWT, plutôt qu’une nouvelle colonne de base de données. Un changement de mot de passe change cette liaison. Les tokens antérieurs au correctif n’ont pas cette liaison et seront refusés : une reconnexion générale est attendue au déploiement. Une modification de profil ne révoque pas les sessions.
|
||||||
|
|
||||||
|
Les chaînes CSV ressemblant à des nombres négatifs sont volontairement exportées comme texte afin de neutraliser les formules. Le chemin XLSX conserve ses valeurs typées.
|
||||||
|
|
||||||
|
## Vérifications exécutées
|
||||||
|
|
||||||
|
- Backend : `npm test -- --runInBand --testPathIgnorePatterns=csv-bookings.security.spec.ts` : 427 tests réussis, 5 déjà ignorés. Après ajout des tests de logs : `users.security.spec.ts` (3 réussis, dont 1 nouveau) et `invitation.security.spec.ts` (1 réussi). Total distinct vérifié hors HTTP : 429 tests.
|
||||||
|
- HTTP backend : `npm test -- --runInBand csv-bookings.security.spec.ts` : 5 tests réussis avec serveur éphémère sur `127.0.0.1`. Première tentative bloquée par le sandbox (`listen EPERM`), puis exécution autorisée réussie.
|
||||||
|
- Frontend : suites `safe-login-redirect.test.ts`, `auth-context.test.tsx`, `csv-cell.test.ts` et `export.test.ts` : 62 tests réussis. Exécutées via Jest/ts-jest et une configuration temporaire explicite pour éviter le chargement automatique des fichiers `.env` par `next/jest`.
|
||||||
|
- `npm run build` backend : réussi.
|
||||||
|
- `npm run type-check` frontend : réussi.
|
||||||
|
- ESLint sans correction automatique sur les fichiers backend/frontend modifiés : réussi.
|
||||||
|
- Prettier sur les fichiers TypeScript modifiés et `git diff --check` : réussis.
|
||||||
|
|
||||||
|
Des échecs intermédiaires ont été corrigés : insertion d’un contrôle au mauvais emplacement détectée par TypeScript, et en-têtes XLSX altérés détectés par les tests d’export. Les vérifications concernées ont ensuite réussi. Aucun assouplissement de sécurité n’a été introduit pour faire passer les tests.
|
||||||
|
|
||||||
|
## Actions de production et travail restant
|
||||||
|
|
||||||
|
1. Renouveler la clé SMTP chez le fournisseur, configurer la nouvelle valeur hors du dépôt, puis déployer. L’historique Git contient encore l’ancienne valeur ; sa suppression du fichier courant n’est pas une révocation.
|
||||||
|
2. Invalider/remplacer les jetons transporteur et invitations susceptibles d’avoir été exposés avant le correctif ; prévoir les nouveaux liens légitimes. Aucun email réel n’a été envoyé ni jeton de production modifié.
|
||||||
|
3. Prévoir la reconnexion des utilisateurs au déploiement et vérifier les parcours navigateur, transporteur et paiement en préproduction.
|
||||||
|
4. Vérifier en environnement réel les limites du reverse proxy, le stockage S3, TLS, les permissions et les journaux historiques. Aucune modification de configuration en production n’a été effectuée.
|
||||||
|
5. Terminer l’audit des fichiers non couverts et l’inventaire des vulnérabilités des dépendances lorsque le scanner redevient disponible. Aucun audit exhaustif de dépendances ni pentest déployé n’est revendiqué ici.
|
||||||
|
|
||||||
|
Les fichiers `.env` et `.env.*` n’ont pas été lus. Aucune migration, fusion, publication, modification de compte fournisseur ou réécriture d’historique n’a été effectuée.
|
||||||
149
docs/security/check-secu/coverage.json
Normal file
149
docs/security/check-secu/coverage.json
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
{
|
||||||
|
"completeness": "partial",
|
||||||
|
"deferred": [
|
||||||
|
{
|
||||||
|
"candidateId": "remaining-source",
|
||||||
|
"id": "remaining-source",
|
||||||
|
"reason": "Agents interrompus par limites d\u2019usage. Pages/composants frontend, migrations/scripts, adaptateurs transporteurs et portions CSV restent non lus int\u00e9gralement ; couverture non exhaustive."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"candidateId": "subscription-sync-binding",
|
||||||
|
"id": "subscription-sync-binding",
|
||||||
|
"reason": "syncFromStripe ne lie pas metadata.organizationId ; UNIQUE stripe_subscription_id bloque le sc\u00e9nario normal. Course avant webhook ou ancien abonnement non li\u00e9 non valid\u00e9s."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"candidate": {
|
||||||
|
"evidence": "login/page.tsx:97 redirect query, auth-context.tsx:110 router.push without URL validation",
|
||||||
|
"title": "Untrusted login redirect reaches router.push"
|
||||||
|
},
|
||||||
|
"candidateId": "login-redirect",
|
||||||
|
"id": "login-redirect",
|
||||||
|
"reason": "Recovering interrupted investigator result for validation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"candidate": {
|
||||||
|
"evidence": "NotificationsGateway forwards notificationId without userId into repository update",
|
||||||
|
"title": "WebSocket mark_as_read lacks ownership"
|
||||||
|
},
|
||||||
|
"candidateId": "notification-owner",
|
||||||
|
"id": "notification-owner",
|
||||||
|
"reason": "Recovering interrupted baseline result"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"candidate": {
|
||||||
|
"evidence": "CsvBookingService.toResponseDto exposes confirmationToken used by public accept/reject",
|
||||||
|
"title": "Creator receives carrier confirmation token"
|
||||||
|
},
|
||||||
|
"candidateId": "carrier-token",
|
||||||
|
"id": "carrier-token",
|
||||||
|
"reason": "Recovering interrupted baseline result"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"documentType": "codex-security.coverage",
|
||||||
|
"excludePaths": [],
|
||||||
|
"explicitExclusions": [
|
||||||
|
{
|
||||||
|
"pattern": "**/.env*",
|
||||||
|
"reason": "Restriction explicite de lecture."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pattern": "**/.env*",
|
||||||
|
"reason": "User prohibits .env and .env.* reads."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"includePaths": [
|
||||||
|
"."
|
||||||
|
],
|
||||||
|
"inventoryStrategy": "repository",
|
||||||
|
"mode": "repository",
|
||||||
|
"openQuestions": [
|
||||||
|
{
|
||||||
|
"question": "Compl\u00e9ter les fichiers non lus int\u00e9gralement avant de qualifier la couverture d\u2019exhaustive."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "V\u00e9rifier rotation SMTP et plafond multipart au proxy sans r\u00e9utiliser le secret."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "V\u00e9rifier liaison Stripe session/organisation et droits des abonnements UNPAID/PAUSED : plusieurs consommateurs lisent seulement plan."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "Aligner DATABASE_SSL, validation TLS SMTP/SQL et buckets provisionn\u00e9s/ACL."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"scanId": "4c194468-0b5f-4f24-9005-5be211dc0e47",
|
||||||
|
"schemaVersion": "1.0",
|
||||||
|
"surfaces": [
|
||||||
|
{
|
||||||
|
"disposition": "reported",
|
||||||
|
"id": "surface_authentification-recuperation-et-websockets",
|
||||||
|
"label": "Authentification, r\u00e9cup\u00e9ration et WebSockets",
|
||||||
|
"notes": "JWT HTTP v\u00e9rifie le type access et le compte actif ; inscription li\u00e9e \u00e0 invitation v\u00e9rifi\u00e9e. Bypass WebSocket, sessions apr\u00e8s reset et secrets dans logs confirm\u00e9s.",
|
||||||
|
"receiptRefs": [],
|
||||||
|
"riskArea": "Sessions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"disposition": "reported",
|
||||||
|
"id": "surface_organisations-et-roles-csv",
|
||||||
|
"label": "Organisations et r\u00f4les CSV",
|
||||||
|
"notes": "Contr\u00f4le inter-organisations cass\u00e9 par casse du r\u00f4le ; liste CSV sans r\u00f4le et mutations VIEWER. Les mutations individuelles CSV v\u00e9rifient le propri\u00e9taire.",
|
||||||
|
"receiptRefs": [],
|
||||||
|
"riskArea": "Isolation et permissions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"disposition": "reported",
|
||||||
|
"id": "surface_liens-transporteurs-et-documents",
|
||||||
|
"label": "Liens transporteurs et documents",
|
||||||
|
"notes": "Jeton divulgu\u00e9 au client. T\u00e9l\u00e9chargements v\u00e9rifient ACCEPTED, mot de passe si configur\u00e9 et appartenance du document ; PDFKit rend du texte sans navigateur ni chargement HTML.",
|
||||||
|
"receiptRefs": [],
|
||||||
|
"riskArea": "Autorit\u00e9 et stockage"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"disposition": "reported",
|
||||||
|
"id": "surface_souscriptions-stripe",
|
||||||
|
"label": "Souscriptions Stripe",
|
||||||
|
"notes": "Signatures v\u00e9rifi\u00e9es ; r\u00e9siliation bloqu\u00e9e par licences. Sync ne compare pas metadata.organizationId mais UNIQUE stripe_subscription_id bloque la r\u00e9association normale ; sc\u00e9nario de course non confirm\u00e9.",
|
||||||
|
"receiptRefs": [],
|
||||||
|
"riskArea": "Int\u00e9grit\u00e9 financi\u00e8re"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"disposition": "no_issue_found",
|
||||||
|
"id": "surface_mcp-et-assistant-ia",
|
||||||
|
"label": "MCP et assistant IA",
|
||||||
|
"notes": "R\u00f4le/offre contr\u00f4l\u00e9s \u00e0 chaque invocation ; acteur li\u00e9 \u00e0 session, SQL des conversations param\u00e9tr\u00e9 avec user_id, quota atomique et tours IA born\u00e9s.",
|
||||||
|
"receiptRefs": [],
|
||||||
|
"riskArea": "Outils et donn\u00e9es"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"disposition": "reported",
|
||||||
|
"id": "surface_frontend-et-exports",
|
||||||
|
"label": "Frontend et exports",
|
||||||
|
"notes": "Redirection brute v\u00e9rifi\u00e9e dans Next install\u00e9. Formules CSV non neutralis\u00e9es. Contexte actif avec cookies HttpOnly, distinct de l\u2019ancien client localStorage.",
|
||||||
|
"receiptRefs": [],
|
||||||
|
"riskArea": "XSS et CSV"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"disposition": "reported",
|
||||||
|
"id": "surface_logs-et-deploiements",
|
||||||
|
"label": "Logs et d\u00e9ploiements",
|
||||||
|
"notes": "Cl\u00e9 SMTP litt\u00e9rale masqu\u00e9e, validit\u00e9 inconnue. Logs de production internes avec NetworkPolicy ; Compose dev expose 3100/3200 sans authentification, sans preuve d\u2019exposition Internet.",
|
||||||
|
"receiptRefs": [],
|
||||||
|
"riskArea": "Secrets et r\u00e9seau"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"disposition": "no_issue_found",
|
||||||
|
"id": "surface_persistance-gdpr-et-configuration",
|
||||||
|
"label": "Persistance, GDPR et configuration",
|
||||||
|
"notes": "Requ\u00eates recherche/GDPR/conversations param\u00e9tr\u00e9es ; export GDPR exclut hash mot de passe, TOTP et hash de cl\u00e9. DATABASE_SSL ignor\u00e9 par runtime/startup et validation de certificat d\u00e9sactiv\u00e9e dans CLI ; buckets distincts, \u00e9tat r\u00e9el externe non test\u00e9. 95 fichiers suivis lus int\u00e9gralement ; lectures cibl\u00e9es suppl\u00e9mentaires non compt\u00e9es.",
|
||||||
|
"receiptRefs": [],
|
||||||
|
"riskArea": "Injection et donn\u00e9es"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"disposition": "rejected",
|
||||||
|
"id": "surface_webhook-ssrf-a-l-enregistrement",
|
||||||
|
"label": "Webhook SSRF \u00e0 l\u2019enregistrement",
|
||||||
|
"notes": "WebhookService poste vers la destination enregistr\u00e9e sans filtre IP, mais les DTO CreateWebhookDto/UpdateWebhookDto n\u2019ont aucun d\u00e9corateur de validation ; la validation globale whitelist + forbidNonWhitelisted de main.ts rejette leurs champs. Aucune voie actuelle de cr\u00e9ation par un attaquant n\u2019a \u00e9t\u00e9 \u00e9tablie. Corriger les DTO doit imp\u00e9rativement ajouter aussi une politique de destination.",
|
||||||
|
"receiptRefs": [],
|
||||||
|
"riskArea": "Requ\u00eates sortantes"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
2614
docs/security/check-secu/findings.json
Normal file
2614
docs/security/check-secu/findings.json
Normal file
File diff suppressed because it is too large
Load Diff
4098
docs/security/check-secu/report.md
Normal file
4098
docs/security/check-secu/report.md
Normal file
File diff suppressed because one or more lines are too long
199
docs/security/check-secu/scan-manifest.json
Normal file
199
docs/security/check-secu/scan-manifest.json
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
{
|
||||||
|
"documentType": "codex-security.scan-manifest",
|
||||||
|
"scan": {
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"mediaType": "application/json",
|
||||||
|
"path": "findings.json",
|
||||||
|
"sha256": "3884e597638c69a493af2c8869cf772ceecdad651eb46e062da9a63ffa6b9314"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mediaType": "application/json",
|
||||||
|
"path": "coverage.json",
|
||||||
|
"sha256": "6a40f0a888d78716b31180cbad762167372a9f7c143d2233a206e5e09d3fc368"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"completedAt": "2026-09-08T12:52:54.338810Z",
|
||||||
|
"coverageRef": "coverage.json",
|
||||||
|
"findingsRef": "findings.json",
|
||||||
|
"id": "4c194468-0b5f-4f24-9005-5be211dc0e47",
|
||||||
|
"preservedSources": {
|
||||||
|
"checkpoints/2554140402e8e806d0fc9066ab498f121c3adda4c2fa8a0cc4d9219f906cfb62.json": "9f224890355cb4ad64f6242d008cd5fd0c7fd6b31be7fcdea64be115b40b5f99",
|
||||||
|
"checkpoints/a0a575a67430a289893d5c590e52fae92e5c3e02dced613366160327d22204c0.json": "6b807e93783851cd3c101fc30c19a678461e5641b094b769b43577bf92286f4b",
|
||||||
|
"checkpoints/a465c6ca7aaac4bec30e86579d857186ed255cd095818d961fdd5afa54583c89.json": "54c03e1544bfc060476d9898825855d75766b95f5a06e2e8bb4d99f749d46d92",
|
||||||
|
"checkpoints/a5174fc1c150ed228f8d64feda7151de15b334da83d207370aca233f5758cdc9.json": "a5174fc1c150ed228f8d64feda7151de15b334da83d207370aca233f5758cdc9",
|
||||||
|
"checkpoints/c2b024a503781cf1fe58a44701b8346e1e33b4ebc93c157dd63b9128884149c3.json": "8933660b6786bc90effde1b588bbfa7e2950bc42ddf84bc93bf339bd19f070ab",
|
||||||
|
"checkpoints/c8dd318728ac16e8c75fd8bf79844283c7877274bc241603a3dddd767a39e476.json": "d3926dbe629975bd1fa807abec36f0a422f87f53b4f756af3ed213d689705c82",
|
||||||
|
"checkpoints/e87e912ce792fff133063edb5e07f8efc27f233f100e856cf46aa948380a16fe.json": "ca71c7837bd2465bb08cfc79a0bbf381273eee526a889e3b0dc8586f884dea66"
|
||||||
|
},
|
||||||
|
"producer": {
|
||||||
|
"name": "codex-security-plugin",
|
||||||
|
"version": "0.1.23"
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"artifactsReviewed": [
|
||||||
|
"apps/backend/src/app.module.ts",
|
||||||
|
"apps/backend/src/application/api-keys/api-keys.service.ts",
|
||||||
|
"apps/backend/src/application/auth/auth.service.ts",
|
||||||
|
"apps/backend/src/application/auth/jwt.strategy.ts",
|
||||||
|
"apps/backend/src/application/controllers/audit.controller.ts",
|
||||||
|
"apps/backend/src/application/controllers/auth.controller.ts",
|
||||||
|
"apps/backend/src/application/controllers/bookings.controller.ts",
|
||||||
|
"apps/backend/src/application/controllers/csv-booking-actions.controller.ts",
|
||||||
|
"apps/backend/src/application/controllers/gdpr.controller.ts",
|
||||||
|
"apps/backend/src/application/controllers/invitations.controller.ts",
|
||||||
|
"apps/backend/src/application/controllers/notifications.controller.ts",
|
||||||
|
"apps/backend/src/application/controllers/organizations.controller.ts",
|
||||||
|
"apps/backend/src/application/controllers/subscriptions.controller.ts",
|
||||||
|
"apps/backend/src/application/controllers/users.controller.ts",
|
||||||
|
"apps/backend/src/application/controllers/webhooks.controller.ts",
|
||||||
|
"apps/backend/src/application/csv-bookings/csv-bookings.module.ts",
|
||||||
|
"apps/backend/src/application/dashboard/dashboard.controller.ts",
|
||||||
|
"apps/backend/src/application/dto/organization.dto.ts",
|
||||||
|
"apps/backend/src/application/dto/subscription.dto.ts",
|
||||||
|
"apps/backend/src/application/dto/user.dto.ts",
|
||||||
|
"apps/backend/src/application/gateways/notifications.gateway.ts",
|
||||||
|
"apps/backend/src/application/guards/api-key-or-jwt.guard.ts",
|
||||||
|
"apps/backend/src/application/guards/feature-flag.guard.ts",
|
||||||
|
"apps/backend/src/application/guards/jwt-auth.guard.ts",
|
||||||
|
"apps/backend/src/application/guards/roles.guard.ts",
|
||||||
|
"apps/backend/src/application/guards/throttle.guard.ts",
|
||||||
|
"apps/backend/src/application/logs/logs.controller.ts",
|
||||||
|
"apps/backend/src/application/mcp/capabilities/account.capabilities.ts",
|
||||||
|
"apps/backend/src/application/mcp/capabilities/admin.capabilities.ts",
|
||||||
|
"apps/backend/src/application/mcp/capabilities/bookings.capabilities.ts",
|
||||||
|
"apps/backend/src/application/mcp/capabilities/knowledge.capabilities.ts",
|
||||||
|
"apps/backend/src/application/mcp/capabilities/rates.capabilities.ts",
|
||||||
|
"apps/backend/src/application/mcp/capability.registry.ts",
|
||||||
|
"apps/backend/src/application/mcp/capability.ts",
|
||||||
|
"apps/backend/src/application/mcp/mcp.controller.ts",
|
||||||
|
"apps/backend/src/application/notifications/notifications.module.ts",
|
||||||
|
"apps/backend/src/application/services/analytics.service.ts",
|
||||||
|
"apps/backend/src/application/services/fuzzy-search.service.ts",
|
||||||
|
"apps/backend/src/application/services/gdpr.service.ts",
|
||||||
|
"apps/backend/src/application/services/invitation.service.ts",
|
||||||
|
"apps/backend/src/application/services/notification.service.ts",
|
||||||
|
"apps/backend/src/application/services/subscription.service.ts",
|
||||||
|
"apps/backend/src/application/services/webhook.service.ts",
|
||||||
|
"apps/backend/src/application/trade-assistant/trade-assistant.controller.ts",
|
||||||
|
"apps/backend/src/application/trade-assistant/trade-assistant.service.ts",
|
||||||
|
"apps/backend/src/domain/entities/subscription.entity.ts",
|
||||||
|
"apps/backend/src/domain/entities/user.entity.ts",
|
||||||
|
"apps/backend/src/domain/services/booking.service.ts",
|
||||||
|
"apps/backend/src/domain/services/capability-access.ts",
|
||||||
|
"apps/backend/src/domain/value-objects/subscription-plan.vo.ts",
|
||||||
|
"apps/backend/src/domain/value-objects/subscription-status.vo.ts",
|
||||||
|
"apps/backend/src/infrastructure/ai/openai-trade.adapter.ts",
|
||||||
|
"apps/backend/src/infrastructure/pdf/pdf.adapter.ts",
|
||||||
|
"apps/backend/src/infrastructure/persistence/typeorm/entities/notification.orm-entity.ts",
|
||||||
|
"apps/backend/src/infrastructure/persistence/typeorm/entities/subscription.orm-entity.ts",
|
||||||
|
"apps/backend/src/infrastructure/persistence/typeorm/mappers/csv-booking.mapper.ts",
|
||||||
|
"apps/backend/src/infrastructure/persistence/typeorm/repositories/csv-booking.repository.ts",
|
||||||
|
"apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-notification.repository.ts",
|
||||||
|
"apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-subscription.repository.ts",
|
||||||
|
"apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-trade-conversation.repository.ts",
|
||||||
|
"apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-trade-quota.repository.ts",
|
||||||
|
"apps/backend/src/infrastructure/security/security.config.ts",
|
||||||
|
"apps/backend/src/infrastructure/storage/s3-storage.adapter.ts",
|
||||||
|
"apps/backend/src/infrastructure/stripe/stripe.adapter.ts",
|
||||||
|
"apps/backend/src/main.ts",
|
||||||
|
"apps/frontend/Dockerfile",
|
||||||
|
"apps/frontend/app/[locale]/layout.tsx",
|
||||||
|
"apps/frontend/app/[locale]/login/page.tsx",
|
||||||
|
"apps/frontend/app/api/health/route.ts",
|
||||||
|
"apps/frontend/i18n/navigation.ts",
|
||||||
|
"apps/frontend/i18n/request.ts",
|
||||||
|
"apps/frontend/i18n/routing.ts",
|
||||||
|
"apps/frontend/lib/api/client.ts",
|
||||||
|
"apps/frontend/middleware.ts",
|
||||||
|
"apps/frontend/next.config.js",
|
||||||
|
"apps/frontend/package.json",
|
||||||
|
"apps/frontend/src/components/ExportButton.tsx",
|
||||||
|
"apps/frontend/src/components/assistant/answer-text.tsx",
|
||||||
|
"apps/frontend/src/components/assistant/message-list.tsx",
|
||||||
|
"apps/frontend/src/components/notifications/notification-row.tsx",
|
||||||
|
"apps/frontend/src/components/providers.tsx",
|
||||||
|
"apps/frontend/src/hooks/use-url-state.ts",
|
||||||
|
"apps/frontend/src/lib/api/client.ts",
|
||||||
|
"apps/frontend/src/lib/context/auth-context.tsx",
|
||||||
|
"apps/frontend/src/utils/export.ts",
|
||||||
|
"apps/frontend/tsconfig.json",
|
||||||
|
"apps/log-exporter/Dockerfile",
|
||||||
|
"apps/log-exporter/package.json",
|
||||||
|
"apps/log-exporter/src/index.js",
|
||||||
|
"docker/docker-compose.full.yml",
|
||||||
|
"infra/logging/loki/loki-config.yml",
|
||||||
|
"infra/prod/k8s/base/06-log-exporter.yaml",
|
||||||
|
"infra/prod/k8s/base/08-traefik-middlewares.yaml",
|
||||||
|
"infra/prod/k8s/base/09-ingress.yaml",
|
||||||
|
"infra/prod/k8s/base/10-network-policies.yaml"
|
||||||
|
],
|
||||||
|
"context": "Mod\u00e8le de menace g\u00e9n\u00e9r\u00e9 depuis le code et revu ind\u00e9pendamment ; aucun mod\u00e8le utilisateur.",
|
||||||
|
"excludePaths": [],
|
||||||
|
"includePaths": [
|
||||||
|
"."
|
||||||
|
],
|
||||||
|
"limitations": [
|
||||||
|
"Couverture source partielle ; aucune attestation d\u2019absence de vuln\u00e9rabilit\u00e9s.",
|
||||||
|
"Fichiers .env/.env.* interdits, non lus.",
|
||||||
|
"Pas d\u2019audit CVE en ligne, de d\u00e9ploiement r\u00e9el, des secrets actifs, permissions cloud ou historique Git."
|
||||||
|
],
|
||||||
|
"runtimeStatus": "Aucun test de p\u00e9n\u00e9tration ni ex\u00e9cution du produit ; v\u00e9rification des biblioth\u00e8ques install\u00e9es par lecture.",
|
||||||
|
"summary": "Audit statique transversal sur check_secu, r\u00e9vision 8446f879b676b303fdb2891388f88ff7e43f5fea.",
|
||||||
|
"validationMode": "Static source trace"
|
||||||
|
},
|
||||||
|
"sealedAt": "2026-09-08T12:52:54.338810Z",
|
||||||
|
"startedAt": "2026-09-07T21:29:14.337312Z",
|
||||||
|
"status": "completed",
|
||||||
|
"target": {
|
||||||
|
"displayName": "xpeditis2.0 copy",
|
||||||
|
"kind": "git_revision",
|
||||||
|
"revision": "8446f879b676b303fdb2891388f88ff7e43f5fea",
|
||||||
|
"targetId": "target_sha256_ddfe0183466d4153b86e8f190318b958432df21c7d3bcaec8c57c2564c3f1208"
|
||||||
|
},
|
||||||
|
"threatModel": {
|
||||||
|
"assets": [
|
||||||
|
"User sessions, API-key authority, organization booking data and subscription entitlements; API-key/JWT authentication paths are distinct (apps/backend/src/application/guards/api-key-or-jwt.guard.ts:34).",
|
||||||
|
"Booking documents, freight rate integrity, published blog assets, AI conversation history and tool access."
|
||||||
|
],
|
||||||
|
"assumptions": [
|
||||||
|
"User origin: requested complete security audit on new check_secu branch from current branch; this independent review performs architecture mapping only. No supplied threat model or knowledge base.",
|
||||||
|
"No first-party SECURITY.md found by resolver inventory; only vendored node_modules policies exist. No .env files read.",
|
||||||
|
"ConfigMap DATABASE_SSL=true and hostssl comments do not mean runtime clients consume TLS: app.module options and startup script omit ssl; CLI data-source consumes true but disables certificate validation (apps/backend/src/app.module.ts:165; apps/backend/scripts/setup/startup.js:13; apps/backend/src/infrastructure/persistence/typeorm/data-source.ts:26; infra/prod/k8s/base/02-configmap-backend.yaml:47).",
|
||||||
|
"ConfigMap AWS_S3_BUCKET=xpeditis-prod-documents affects CSV object loading; separate booking documents/PDF/blog consumers hardcode other buckets. Object-store policies and provisioned bucket existence remain external prerequisites (infra/prod/k8s/base/02-configmap-backend.yaml:71; apps/backend/src/application/services/csv-booking.service.ts:1269; apps/backend/src/application/services/booking-automation.service.ts:100; apps/backend/src/application/controllers/blog.controller.ts:23).",
|
||||||
|
"Current code uses httpOnly auth cookies; repository overview claiming localStorage token architecture is not sufficient evidence of current implementation (apps/backend/src/application/controllers/auth.controller.ts:108; apps/backend/src/application/auth/jwt.strategy.ts:40).",
|
||||||
|
"Coverage is architectural, not a completed vulnerability audit. External IAM, deployed networking, CI secrets, tenant enforcement of every handler, refresh lifecycle and carrier-token entropy/expiry are not fully established by this pass.",
|
||||||
|
"Nest TypeORM / production ConfigMap: DATABASE_HOST/PORT/NAME from ConfigService; DATABASE_SSL declared but absent from TypeORM options => 10.10.1.20:5432/xpeditis_prod; no explicit TLS option. Contr\u00f4le: synchronize:false; server pg_hba controls admission. Runtime factory does not consume DATABASE_SSL; deployment success and ambient driver options remain unknown Sources: apps/backend/src/app.module.ts:165, infra/prod/k8s/base/02-configmap-backend.yaml:44",
|
||||||
|
"TypeORM migration CLI / production migration Job: Job calls compiled data-source; DATABASE_SSL=true from ConfigMap => 10.10.1.20:5432/xpeditis_prod with ssl.rejectUnauthorized=false. Contr\u00f4le: TLS encryption without certificate validation in data-source. Sources: infra/prod/k8s/base/07-migration-job.yaml:52, apps/backend/src/infrastructure/persistence/typeorm/data-source.ts:26",
|
||||||
|
"Startup pg client and migration DataSource / image startup script: DATABASE_* directly consumed; no ssl option => configured PostgreSQL target, including prod target when prod ConfigMap injected. Contr\u00f4le: database credential and server admission. Different TLS behavior from migration Job; Job explicitly documents this at 07-migration-job.yaml:52 Sources: apps/backend/scripts/setup/startup.js:13, apps/backend/scripts/setup/startup.js:40",
|
||||||
|
"CSV object loader / production/object-storage configured: company config metadata.minioObjectKey; AWS_S3_BUCKET from ConfigMap; storage adapter AWS_S3_ENDPOINT => https://fsn1.your-objectstorage.com/xpeditis-prod-documents/{metadata.minioObjectKey}. Contr\u00f4le: S3 credential permissions; fallback to local file on error. Sources: apps/backend/src/infrastructure/carriers/csv-loader/csv-rate-loader.adapter.ts:149, apps/backend/src/infrastructure/storage/s3-storage.adapter.ts:244, infra/prod/k8s/base/02-configmap-backend.yaml:70",
|
||||||
|
"CSV local loader / local and object-store fallback: absolute filePath unchanged; otherwise process.cwd()/src/infrastructure/storage/csv-storage/rates joined with filePath => {cwd}/src/infrastructure/storage/csv-storage/rates/{relative filePath}, or absolute filePath. Contr\u00f4le: host filesystem permissions and administrative configuration authority. Sources: apps/backend/src/infrastructure/carriers/csv-loader/csv-rate-loader.adapter.ts:125, apps/backend/src/infrastructure/carriers/csv-loader/csv-rate-loader.adapter.ts:164, apps/backend/src/infrastructure/carriers/csv-loader/csv-rate-loader.adapter.ts:287",
|
||||||
|
"Booking document upload/download / all S3 deployments including prod: hardcoded bucket; document key constructed in service; endpoint from adapter => xpeditis-documents/csv-bookings/{bookingId}/{documentId}-{originalFilename}; prod endpoint https://fsn1.your-objectstorage.com. Contr\u00f4le: carrier token, ACCEPTED status, password hash when present, document belongs to token booking. AWS_S3_BUCKET=xpeditis-prod-documents does not select this bucket Sources: apps/backend/src/application/services/csv-booking.service.ts:1269, apps/backend/src/application/services/csv-booking.service.ts:848, apps/backend/src/application/services/csv-booking.service.ts:866",
|
||||||
|
"Booking PDF automation / all S3 deployments: hardcoded bucket and booking-derived key => xpeditis-bookings/bookings/{booking.id}/{booking.bookingNumber.value}.pdf. Contr\u00f4le: backend automation and S3 credentials. AWS_S3_BUCKET does not select this bucket Sources: apps/backend/src/application/services/booking-automation.service.ts:98",
|
||||||
|
"Blog image API / all S3 deployments: hardcoded bucket; public route constructs blog-images filename key => xpeditis-blog/blog-images/{filename}. Contr\u00f4le: public publication workflow and S3 credentials. AWS_S3_BUCKET does not select this bucket Sources: apps/backend/src/application/controllers/blog.controller.ts:23, apps/backend/src/application/controllers/blog.controller.ts:27, apps/backend/src/application/controllers/blog.controller.ts:76",
|
||||||
|
"Trade assistant AI / configured OPENAI_API_KEY: fixed Responses endpoint; OPENAI_MODEL defaults gpt-4.1-mini => https://api.openai.com/v1/responses; question/history/passages and invoked tool outcomes. Contr\u00f4le: actor-bound registry invocation; 4 tool rounds, 800 output tokens, store:false, 30 second timeout. Sources: apps/backend/src/infrastructure/ai/openai-trade.adapter.ts:51, apps/backend/src/infrastructure/ai/openai-trade.adapter.ts:115, apps/backend/src/application/trade-assistant/trade-assistant.service.ts:162, apps/backend/src/application/trade-assistant/trade-assistant.service.ts:216"
|
||||||
|
],
|
||||||
|
"attackerCapabilities": [
|
||||||
|
"Unauthenticated caller can request public endpoints and supply arbitrary ordinary request input, but is not assumed to possess carrier token, password, Stripe signing secret, administrative API key or deployment control.",
|
||||||
|
"Authenticated organization user controls their requests and AI questions; crossing into another tenant, administrative capability or higher-plan entitlement would be a new authority gain. MCP visibility alone is not permission evidence; registry invocation enforces policy (apps/backend/src/application/mcp/capability.registry.ts:85).",
|
||||||
|
"Privileged CSV configuration/import and release operators are separate conditional workflows; ordinary remote callers are not assumed to control local files, deployment variables, or migration scripts."
|
||||||
|
],
|
||||||
|
"securityObjectives": [
|
||||||
|
"Preserve organization and document ownership across API, MCP and AI handlers; evaluate handler-level scoping separately from global authentication.",
|
||||||
|
"Keep public token capabilities scoped to intended booking and action; enforce additional document password/state controls at every document consumer (apps/backend/src/application/services/csv-booking.service.ts:848).",
|
||||||
|
"Bind financial state changes to verified Stripe events; protect credentials and sensitive object contents with actual consumed storage/database configuration.",
|
||||||
|
"Retain effective resource distinctions: CSV configured bucket, document/PDF/blog hardcoded buckets, and distinct database startup versus migration TLS behavior."
|
||||||
|
],
|
||||||
|
"summary": "Xpeditis freight platform uses Nest API with relational storage, CSV shipping rates, booking documents, subscription payments, MCP and AI assistant. Global ApiKeyOrJwtGuard and throttling protect normal API routes; public carrier links and Stripe webhook have separate authority checks (apps/backend/src/app.module.ts:210; apps/backend/src/application/controllers/csv-booking-actions.controller.ts:28; apps/backend/src/application/controllers/subscriptions.controller.ts:262). Production manifests describe Kubernetes plus private PostgreSQL and external object storage; actual deployment state is not supplied.",
|
||||||
|
"trustBoundaries": [
|
||||||
|
"Browser to API: JWT extraction accepts httpOnly accessToken cookie; auth endpoints set cookies and security config defaults SameSite=lax with production Secure (apps/backend/src/application/auth/jwt.strategy.ts:40; apps/backend/src/application/controllers/auth.controller.ts:108; apps/backend/src/infrastructure/security/security.config.ts:195). Helmet/CORS/validation are applied at startup (apps/backend/src/main.ts:33; apps/backend/src/main.ts:42; apps/backend/src/main.ts:54).",
|
||||||
|
"External API key caller to application: key validation supplies user context; absent key falls back to JWT (apps/backend/src/application/guards/api-key-or-jwt.guard.ts:34).",
|
||||||
|
"MCP tools/list visibility is separate from invocation enforcement: registry checks role and plan again and parses schema before handler execution, recording audit (apps/backend/src/application/mcp/capability.registry.ts:85; apps/backend/src/application/mcp/capability.registry.ts:100).",
|
||||||
|
"AI invocation is bound to authenticated actor and uses the same capability registry; capability scope is not inherently read-only. Quota reserved before model request (apps/backend/src/application/trade-assistant/trade-assistant.service.ts:146; apps/backend/src/application/trade-assistant/trade-assistant.service.ts:216).",
|
||||||
|
"Carrier email token permits public accept/reject actions; document delivery independently requires accepted booking and password when hash exists (apps/backend/src/application/controllers/csv-booking-actions.controller.ts:28; apps/backend/src/application/services/csv-booking.service.ts:729; apps/backend/src/application/services/csv-booking.service.ts:848).",
|
||||||
|
"Stripe webhook is public and passes raw request body/signature to service, with adapter constructEvent verification using configured webhook secret (apps/backend/src/application/controllers/subscriptions.controller.ts:262; apps/backend/src/infrastructure/stripe/stripe.adapter.ts:251)."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schemaVersion": "1.0"
|
||||||
|
}
|
||||||
@ -113,6 +113,18 @@ make preflight
|
|||||||
|
|
||||||
## Règles de sécurité non négociables
|
## Règles de sécurité non négociables
|
||||||
|
|
||||||
|
PostgreSQL : avant de déployer les correctifs TLS, renseigner `DATABASE_SSL_CA`
|
||||||
|
dans le Secret backend chiffré SOPS avec le contenu PEM du certificat **public**
|
||||||
|
`/var/lib/xpeditis/certs/server.crt` de db-01, récupéré par un canal d’administration
|
||||||
|
authentifié. Ne jamais copier `server.key`. Le gabarit de secrets contient le
|
||||||
|
champ à renseigner. Le backend et le Job de migration utilisent ce même Secret.
|
||||||
|
Conserver `DATABASE_SSL=true` et un `DATABASE_HOST` présent dans les SAN du
|
||||||
|
certificat (IP privée ou nom DNS). Les certificats non approuvés et les noms
|
||||||
|
incorrects sont désormais refusés ; le réseau privé ne remplace pas ce contrôle.
|
||||||
|
Lors d’un renouvellement, distribuer le nouveau certificat de confiance avant
|
||||||
|
la bascule serveur et redémarrer les clients concernés. Ne pas désactiver la
|
||||||
|
vérification TLS pour contourner une erreur de certificat.
|
||||||
|
|
||||||
1. **Aucun secret en clair dans Git.** Uniquement des fichiers `*.sops.yaml`
|
1. **Aucun secret en clair dans Git.** Uniquement des fichiers `*.sops.yaml`
|
||||||
chiffrés avec age. `make secrets-check` refuse le contraire.
|
chiffrés avec age. `make secrets-check` refuse le contraire.
|
||||||
2. **La base de données n'est jamais joignable depuis Internet.** Réseau privé,
|
2. **La base de données n'est jamais joignable depuis Internet.** Réseau privé,
|
||||||
|
|||||||
@ -32,6 +32,11 @@ stringData:
|
|||||||
# --- Base de donnees -------------------------------------------------------
|
# --- Base de donnees -------------------------------------------------------
|
||||||
DATABASE_USER: "xpeditis"
|
DATABASE_USER: "xpeditis"
|
||||||
DATABASE_PASSWORD: "REMPLACER" # identique a POSTGRES_PASSWORD de db-01
|
DATABASE_PASSWORD: "REMPLACER" # identique a POSTGRES_PASSWORD de db-01
|
||||||
|
# Certificat PUBLIC PEM de db-01 (/var/lib/xpeditis/certs/server.crt),
|
||||||
|
# obtenu via un canal d'administration authentifie. Jamais server.key.
|
||||||
|
# Necessaire pour authentifier le certificat auto-signe de PostgreSQL.
|
||||||
|
DATABASE_SSL_CA: |
|
||||||
|
REMPLACER_PAR_LE_CERTIFICAT_PUBLIC_PEM_DE_DB_01
|
||||||
|
|
||||||
# --- Redis -----------------------------------------------------------------
|
# --- Redis -----------------------------------------------------------------
|
||||||
REDIS_PASSWORD: "REMPLACER" # identique a REDIS_PASSWORD de db-01
|
REDIS_PASSWORD: "REMPLACER" # identique a REDIS_PASSWORD de db-01
|
||||||
|
|||||||
@ -49,8 +49,8 @@ spec:
|
|||||||
- name: migrate
|
- name: migrate
|
||||||
image: rg.fr-par.scw.cloud/weworkstudio/xpeditis-backend:__IMAGE_TAG__
|
image: rg.fr-par.scw.cloud/weworkstudio/xpeditis-backend:__IMAGE_TAG__
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
# CLI TypeORM sur la source de donnees compilee. Elle honore
|
# CLI TypeORM sur la source de donnees compilee. API, CLI et startup
|
||||||
# DATABASE_SSL, contrairement au client de secours de startup.js.
|
# partagent DATABASE_SSL et DATABASE_SSL_CA avec verification TLS.
|
||||||
command:
|
command:
|
||||||
- node
|
- node
|
||||||
- ./node_modules/typeorm/cli.js
|
- ./node_modules/typeorm/cli.js
|
||||||
|
|||||||
@ -87,11 +87,10 @@ systemctl enable --now docker
|
|||||||
systemctl restart docker
|
systemctl restart docker
|
||||||
|
|
||||||
# --- 3. Certificat TLS de PostgreSQL ----------------------------------------
|
# --- 3. Certificat TLS de PostgreSQL ----------------------------------------
|
||||||
# Certificat auto-signe : PostgreSQL n'est joignable que depuis app-01 sur un
|
# Certificat auto-signe : distribuer server.crt (public) aux clients via
|
||||||
# reseau prive, il n'y a pas de tiers a authentifier. Ce certificat sert a
|
# DATABASE_SSL_CA, par un canal d'administration authentifie. Les clients
|
||||||
# CHIFFRER le transport, pas a prouver une identite publique.
|
# verifient le certificat et son SAN, meme sur le reseau prive.
|
||||||
# Cote client, DATABASE_SSL=true avec rejectUnauthorized:false accepte ce
|
# Ne jamais distribuer server.key ni desactiver rejectUnauthorized.
|
||||||
# certificat : c'est coherent, et documente dans 04-noeud-donnees.md.
|
|
||||||
if [[ ! -f "${DATA_ROOT}/certs/server.key" ]]; then
|
if [[ ! -f "${DATA_ROOT}/certs/server.key" ]]; then
|
||||||
log "Generation du certificat TLS PostgreSQL (10 ans)"
|
log "Generation du certificat TLS PostgreSQL (10 ans)"
|
||||||
openssl req -new -x509 -days 3650 -nodes \
|
openssl req -new -x509 -days 3650 -nodes \
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user