Compare commits

..

6 Commits

Author SHA1 Message Date
David
8d2193aaec gitignore 2026-09-22 21:25:28 +02:00
David
a386e19aa2 fix docker compose 2026-09-22 21:23:49 +02:00
David
7fd6eeccae fix(security): protect wiki review and administrator bootstrap 2026-09-22 09:10:49 +02:00
David
ef0d7d5f67 merge: integrate check_secu protections into ia 2026-09-18 13:01:45 +02:00
David
a0ac0379eb fix(security): enforce live entitlements and protect credentials 2026-09-18 12:57:39 +02:00
David
c09b8be9ae feature secu 2026-09-14 11:19:29 +02:00
134 changed files with 11790 additions and 447 deletions

1
.gitignore vendored
View File

@ -46,6 +46,7 @@ lerna-debug.log*
docker-compose.override.yml docker-compose.override.yml
stack-portainer.yaml stack-portainer.yaml
tmp.stack-portainer.yaml tmp.stack-portainer.yaml
stack-portainer-preprod.yaml
# Uploads # Uploads
uploads/ uploads/

View File

@ -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

View File

@ -1,5 +1,15 @@
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 { SafeDatabaseLogger } = require(
path.join(applicationRoot, 'dist/infrastructure/persistence/typeorm/safe-database-logger')
);
const AppDataSource = new DataSource({ const AppDataSource = new DataSource({
type: 'postgres', type: 'postgres',
@ -8,10 +18,19 @@ 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,
logger: new SafeDatabaseLogger(true),
migrationsTransactionMode: 'all',
}); });
console.log('🚀 Starting Xpeditis Backend Migration Script...'); console.log('🚀 Starting Xpeditis Backend Migration Script...');
@ -39,6 +58,6 @@ AppDataSource.initialize()
}) })
.catch(error => { .catch(error => {
console.error('❌ Error during migration:'); console.error('❌ Error during migration:');
console.error(error); console.error('Check migration prerequisites and database availability.');
process.exit(1); process.exit(1);
}); });

View File

@ -4,6 +4,17 @@ 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')
);
const { SafeDatabaseLogger } = require(
path.join(applicationRoot, 'dist/infrastructure/persistence/typeorm/safe-database-logger')
);
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 +27,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,10 +58,19 @@ 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,
logger: new SafeDatabaseLogger(true),
migrationsTransactionMode: 'all',
}); });
try { try {
@ -67,7 +92,7 @@ async function runMigrations() {
console.log('✅ Database migrations completed'); console.log('✅ Database migrations completed');
return true; return true;
} catch (error) { } catch (error) {
console.error('❌ Error during migration:', error); console.error('❌ Migration failed. Check migration prerequisites and database availability.');
process.exit(1); process.exit(1);
} }
} }
@ -78,6 +103,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 +122,11 @@ async function main() {
startApplication(); startApplication();
} }
main().catch(error => { if (require.main === module) {
console.error('❌ Startup failed:', error); main().catch(error => {
console.error('❌ Startup failed. Check migration prerequisites and database availability.');
process.exit(1); process.exit(1);
}); });
}
module.exports = { waitForPostgres, runMigrations };

View File

@ -1,3 +1,5 @@
import { SafeDatabaseLogger } from './infrastructure/persistence/typeorm/safe-database-logger';
import { safeHttpSerializers } from './application/logging/safe-http-log';
import { TradeAssistantModule } from './application/trade-assistant/trade-assistant.module'; import { TradeAssistantModule } from './application/trade-assistant/trade-assistant.module';
import { McpModule } from './application/mcp/mcp.module'; import { McpModule } from './application/mcp/mcp.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -16,6 +18,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';
@ -62,6 +65,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(),
@ -118,6 +123,7 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
return { return {
pinoHttp: { pinoHttp: {
serializers: safeHttpSerializers,
transport: usePretty transport: usePretty
? { ? {
target: 'pino-pretty', target: 'pino-pretty',
@ -178,9 +184,15 @@ 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),
logger: new SafeDatabaseLogger(configService.get('DATABASE_LOGGING', false)),
autoLoadEntities: true, // Auto-load entities from forFeature() autoLoadEntities: true, // Auto-load entities from forFeature()
}), }),
inject: [ConfigService], inject: [ConfigService],

View File

@ -0,0 +1,88 @@
import { ForbiddenException } from '@nestjs/common';
import { ApiKey } from '@domain/entities/api-key.entity';
import { Subscription } from '@domain/entities/subscription.entity';
import { SubscriptionPlan } from '@domain/value-objects/subscription-plan.vo';
import {
SubscriptionStatus,
SubscriptionStatusType,
} from '@domain/value-objects/subscription-status.vo';
import { ApiKeyRepository } from '@domain/ports/out/api-key.repository';
import { UserRepository } from '@domain/ports/out/user.repository';
import { SubscriptionRepository } from '@domain/ports/out/subscription.repository';
import { ApiKeysService } from './api-keys.service';
describe('API key current entitlement', () => {
const setup = () => {
let subscription = Subscription.create({
id: 'sub',
organizationId: 'org',
plan: SubscriptionPlan.gold(),
});
const key = ApiKey.create({
id: 'key',
userId: 'user',
organizationId: 'org',
name: 'test',
keyHash: 'hash',
keyPrefix: 'xped_live_test',
});
const keys = {
findByKeyHash: jest.fn().mockResolvedValue(key),
save: jest.fn().mockImplementation(async value => value),
};
const users = {
findById: jest
.fn()
.mockResolvedValue({ id: 'user', organizationId: 'org', isActive: true, role: 'MANAGER' }),
};
const subscriptions = {
findByOrganizationId: jest.fn().mockImplementation(async () => subscription),
};
return {
service: new ApiKeysService(
keys as unknown as ApiKeyRepository,
users as unknown as UserRepository,
subscriptions as unknown as SubscriptionRepository
),
keys,
setStatus: (status: SubscriptionStatusType) => {
subscription = subscription.updateStatus(SubscriptionStatus.create(status));
},
};
};
it.each<SubscriptionStatusType>([
'UNPAID',
'PAUSED',
'INCOMPLETE',
'INCOMPLETE_EXPIRED',
'CANCELED',
])('%s invalidates an existing key and forbids creation', async status => {
const { service, keys, setStatus } = setup();
await expect(service.validateAndGetUser('xped_live_test')).resolves.toMatchObject({
plan: 'GOLD',
});
keys.save.mockClear();
setStatus(status);
await expect(service.validateAndGetUser('xped_live_test')).resolves.toBeNull();
await expect(service.generateApiKey('user', 'org', { name: 'new' })).rejects.toBeInstanceOf(
ForbiddenException
);
expect(keys.save).not.toHaveBeenCalled();
});
it.each<SubscriptionStatusType>(['ACTIVE', 'TRIALING', 'PAST_DUE'])(
'%s permits keys',
async status => {
const { service, setStatus } = setup();
setStatus(status);
await expect(service.validateAndGetUser('xped_live_test')).resolves.toMatchObject({
plan: 'GOLD',
});
await expect(service.generateApiKey('user', 'org', { name: 'new' })).resolves.toMatchObject({
name: 'new',
isActive: true,
});
}
);
});

View File

@ -154,8 +154,8 @@ export class ApiKeysService {
organizationId: user.organizationId, organizationId: user.organizationId,
firstName: user.firstName, firstName: user.firstName,
lastName: user.lastName, lastName: user.lastName,
plan: subscription.plan.value, plan: subscription.accessPlan.value,
planFeatures: [...subscription.plan.planFeatures], planFeatures: [...subscription.accessPlan.planFeatures],
}; };
} }

View 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();
});
});

View File

@ -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
} }
@ -253,7 +254,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');
@ -413,13 +414,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
*/ */
@ -447,8 +457,8 @@ export class AuthService {
const subscription = await this.subscriptionService.getOrCreateSubscription( const subscription = await this.subscriptionService.getOrCreateSubscription(
user.organizationId user.organizationId
); );
plan = subscription.plan.value; plan = subscription.accessPlan.value;
planFeatures = [...subscription.plan.planFeatures]; planFeatures = [...subscription.accessPlan.planFeatures];
} catch (error) { } catch (error) {
this.logger.warn(`Failed to fetch subscription for JWT: ${error}`); this.logger.warn(`Failed to fetch subscription for JWT: ${error}`);
} }
@ -462,6 +472,7 @@ export class AuthService {
plan, plan,
planFeatures, planFeatures,
type: 'access', type: 'access',
credentialVersion: this.credentialVersion(user),
}; };
const refreshPayload: JwtPayload = { const refreshPayload: JwtPayload = {
@ -472,6 +483,7 @@ export class AuthService {
plan, plan,
planFeatures, planFeatures,
type: 'refresh', type: 'refresh',
credentialVersion: this.credentialVersion(user),
rememberMe, rememberMe,
}; };

View File

@ -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
} }

View File

@ -117,7 +117,7 @@ export class BookingsController {
const subscription = await this.subscriptionService.getOrCreateSubscription( const subscription = await this.subscriptionService.getOrCreateSubscription(
user.organizationId user.organizationId
); );
const maxShipments = subscription.plan.maxShipmentsPerYear; const maxShipments = subscription.maxShipmentsPerYear;
if (maxShipments !== -1) { if (maxShipments !== -1) {
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const count = await this.shipmentCounter.countShipmentsForOrganizationInYear( const count = await this.shipmentCounter.countShipmentsForOrganizationInYear(

View File

@ -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');
} }
@ -171,7 +178,7 @@ export class CsvBookingsController {
if (req.user.role !== 'ADMIN') { if (req.user.role !== 'ADMIN') {
// Check the paid-reservation limit (free/Bronze plan = 5 paid shipments/year) // Check the paid-reservation limit (free/Bronze plan = 5 paid shipments/year)
const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId); const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId);
const maxShipments = subscription.plan.maxShipmentsPerYear; const maxShipments = subscription.maxShipmentsPerYear;
if (maxShipments !== -1) { if (maxShipments !== -1) {
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const count = await this.shipmentCounter.countPaidShipmentsForOrganizationInYear( const count = await this.shipmentCounter.countPaidShipmentsForOrganizationInYear(
@ -248,7 +255,7 @@ export class CsvBookingsController {
): Promise<{ max: number; used: number; unlimited: boolean; limitReached: boolean }> { ): Promise<{ max: number; used: number; unlimited: boolean; limitReached: boolean }> {
const organizationId = req.user.organizationId; const organizationId = req.user.organizationId;
const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId); const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId);
const max = subscription.plan.maxShipmentsPerYear; const max = subscription.maxShipmentsPerYear;
const unlimited = max === -1; const unlimited = max === -1;
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const used = await this.shipmentCounter.countPaidShipmentsForOrganizationInYear( const used = await this.shipmentCounter.countPaidShipmentsForOrganizationInYear(
@ -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({
@ -417,6 +428,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({
@ -465,6 +478,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({
@ -508,6 +523,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({
@ -577,6 +594,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({
@ -606,6 +625,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({
@ -631,6 +652,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({
@ -662,6 +685,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({
@ -693,9 +718,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',
@ -749,9 +786,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',
@ -818,6 +861,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({

View File

@ -0,0 +1,119 @@
import { ExecutionContext, INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import request from 'supertest';
import { Subscription } from '@domain/entities/subscription.entity';
import { SubscriptionPlan } from '@domain/value-objects/subscription-plan.vo';
import { ShipmentLimitExceededException } from '@domain/exceptions/shipment-limit-exceeded.exception';
import { CreateCsvBookingDto } from '../dto/csv-booking.dto';
import { SubscriptionStatus } from '@domain/value-objects/subscription-status.vo';
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;
let subscription: Subscription;
const countPaidShipmentsForOrganizationInYear = jest.fn().mockResolvedValue(0);
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 () => subscription,
},
},
{ provide: ConfigService, useValue: {} },
{ provide: SHIPMENT_COUNTER_PORT, useValue: { countPaidShipmentsForOrganizationInYear } },
{ 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();
subscription = Subscription.create({
id: 'sub',
organizationId: 'org',
plan: SubscriptionPlan.gold(),
});
countPaidShipmentsForOrganizationInYear.mockResolvedValue(0);
});
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('applies the Bronze quota after a paid subscription is suspended', async () => {
subscription = subscription.updateStatus(SubscriptionStatus.create('UNPAID'));
countPaidShipmentsForOrganizationInYear.mockResolvedValue(
SubscriptionPlan.bronze().maxShipmentsPerYear
);
await expect(
app
.get(CsvBookingsController)
.createBooking({} as CreateCsvBookingDto, [{} as Express.Multer.File], {
user: { id: 'user', organizationId: 'org', role: 'USER' },
})
).rejects.toBeInstanceOf(ShipmentLimitExceededException);
expect(createBooking).not.toHaveBeenCalled();
expect(countPaidShipmentsForOrganizationInYear).toHaveBeenCalledWith(
'org',
new Date().getFullYear()
);
});
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);
});
});

View File

@ -119,7 +119,7 @@ export class InvitationsController {
description: 'Invitation expired or already used', description: 'Invitation expired or already used',
}) })
async verifyInvitation(@Param('token') token: string): Promise<InvitationResponseDto> { async verifyInvitation(@Param('token') token: string): Promise<InvitationResponseDto> {
this.logger.log(`Verifying invitation token: ${token}`); this.logger.log('Verifying invitation token');
const invitation = await this.invitationService.verifyInvitation(token); const invitation = await this.invitationService.verifyInvitation(token);

View File

@ -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 };
} }

View File

@ -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();
});
});

View File

@ -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');
} }

View File

@ -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');
} }
} }
} }

View File

@ -208,8 +208,7 @@ export class UsersController {
this.logger.log(`Access email sent to new user ${newUser.email}`); this.logger.log(`Access email sent to new user ${newUser.email}`);
return true; return true;
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error); this.logger.error('User created but the access email failed');
this.logger.error(`User ${newUser.email} created but the access email failed: ${message}`);
return false; return false;
} }
} }
@ -299,6 +298,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');

View File

@ -0,0 +1,88 @@
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,
{} as never,
{ assertKeepsAnActiveAdmin: jest.fn() } as never,
{ sendUserInvitation: jest.fn().mockResolvedValue(undefined) } as never,
{ findById: jest.fn().mockResolvedValue(null) } as never
);
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,
{} as never,
{ assertKeepsAnActiveAdmin: jest.fn() } as never,
{ sendUserInvitation: jest.fn().mockResolvedValue(undefined) } as never,
{ findById: jest.fn().mockResolvedValue(null) } as never
);
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();
}
});
});

View File

@ -0,0 +1,28 @@
import 'reflect-metadata';
import { I18nValidationPipe } from 'nestjs-i18n';
import { WebhooksController } from './webhooks.controller';
describe('Current webhook configuration boundary (OBS-02)', () => {
it.each([
['createWebhook', 0],
['updateWebhook', 1],
])('%s rejects an unvalidated destination at the global pipe', async (method, index) => {
const types = Reflect.getMetadata(
'design:paramtypes',
WebhooksController.prototype,
method as string
);
const pipe = new I18nValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: { enableImplicitConversion: true },
});
await expect(
pipe.transform(
{ url: 'http://127.0.0.1/internal', events: ['booking.created'] },
{ type: 'body', metatype: types[index as number] }
)
).rejects.toMatchObject({ status: 400 });
});
});

View File

@ -554,12 +554,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',

View File

@ -1,3 +1,4 @@
import { safeRequestRoute } from '../logging/safe-http-log';
/** /**
* DomainExceptionFilter * DomainExceptionFilter
* *
@ -38,7 +39,7 @@ export class DomainExceptionFilter implements ExceptionFilter {
error: exception.name, error: exception.name,
message: typeof translated === 'string' ? translated : exception.message, message: typeof translated === 'string' ? translated : exception.message,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
path: request.url, path: safeRequestRoute(request),
}); });
} }
} }

View File

@ -1,3 +1,4 @@
import { safeRequestRoute } from '../logging/safe-http-log';
import { import {
ArgumentsHost, ArgumentsHost,
Catch, Catch,
@ -80,8 +81,7 @@ export class UnhandledExceptionFilter implements ExceptionFilter {
const reference = randomUUID().slice(0, 8); const reference = randomUUID().slice(0, 8);
this.logger.error( this.logger.error(
`[${reference}] ${request.method} ${request.url} — ${describe(exception)}`, `[${reference}] ${request.method} ${safeRequestRoute(request)} — ${unavailable ? 'dependency unavailable' : 'unexpected error'}`
exception instanceof Error ? exception.stack : undefined
); );
response.status(status).json({ response.status(status).json({
@ -91,7 +91,7 @@ export class UnhandledExceptionFilter implements ExceptionFilter {
message: this.translate(key, lang), message: this.translate(key, lang),
reference, reference,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
path: request.url, path: safeRequestRoute(request),
}); });
} }
@ -112,9 +112,6 @@ export function isLastActiveAdminViolation(exception: unknown): boolean {
return code === LAST_ACTIVE_ADMIN_SQLSTATE || driverError?.code === LAST_ACTIVE_ADMIN_SQLSTATE; return code === LAST_ACTIVE_ADMIN_SQLSTATE || driverError?.code === LAST_ACTIVE_ADMIN_SQLSTATE;
} }
const describe = (exception: unknown): string =>
exception instanceof Error ? `${exception.name}: ${exception.message}` : String(exception);
/** /**
* L'erreur vient-elle d'une dependance injoignable, plutot que d'une requete * L'erreur vient-elle d'une dependance injoignable, plutot que d'une requete
* fautive ou d'un defaut du code ? * fautive ou d'un defaut du code ?

View File

@ -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();
});
});

View File

@ -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');
}
} }
/** /**

View File

@ -18,7 +18,7 @@ import { REQUIRED_FEATURES_KEY } from '../decorators/requires-feature.decorator'
* Feature Flag Guard * Feature Flag Guard
* *
* Checks if the user's subscription plan includes the required features. * Checks if the user's subscription plan includes the required features.
* First tries to read plan from JWT payload (fast path), falls back to DB lookup. * Uses current subscription data so stale token claims cannot preserve revoked rights.
* *
* Usage: * Usage:
* @UseGuards(JwtAuthGuard, RolesGuard, FeatureFlagGuard) * @UseGuards(JwtAuthGuard, RolesGuard, FeatureFlagGuard)
@ -58,19 +58,7 @@ export class FeatureFlagGuard implements CanActivate {
return true; return true;
} }
// Fast path: check plan features from JWT payload // Always resolve current rights, including after suspension or downgrade.
if (user.planFeatures && Array.isArray(user.planFeatures)) {
const hasAllFeatures = requiredFeatures.every(feature => user.planFeatures.includes(feature));
if (hasAllFeatures) {
return true;
}
// JWT says no — but JWT might be stale after an upgrade.
// Fall through to DB check.
}
// Slow path: DB lookup for fresh subscription data
try { try {
const subscription = await this.subscriptionRepository.findByOrganizationId( const subscription = await this.subscriptionRepository.findByOrganizationId(
user.organizationId user.organizationId
@ -81,8 +69,9 @@ export class FeatureFlagGuard implements CanActivate {
this.throwFeatureRequired(requiredFeatures); this.throwFeatureRequired(requiredFeatures);
} }
const plan = subscription!.plan; const missingFeatures = requiredFeatures.filter(
const missingFeatures = requiredFeatures.filter(feature => !plan.hasFeature(feature)); feature => !subscription!.hasFeature(feature)
);
if (missingFeatures.length > 0) { if (missingFeatures.length > 0) {
this.throwFeatureRequired(requiredFeatures); this.throwFeatureRequired(requiredFeatures);

View File

@ -0,0 +1,90 @@
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Subscription } from '@domain/entities/subscription.entity';
import { SubscriptionPlan } from '@domain/value-objects/subscription-plan.vo';
import {
SubscriptionStatus,
SubscriptionStatusType,
} from '@domain/value-objects/subscription-status.vo';
import { SubscriptionRepository } from '@domain/ports/out/subscription.repository';
import { FeatureFlagGuard } from './feature-flag.guard';
const subscriptionFor = (status: SubscriptionStatusType) =>
Subscription.create({
id: 'sub',
organizationId: 'org',
plan: SubscriptionPlan.gold(),
}).updateStatus(SubscriptionStatus.create(status));
const denied: SubscriptionStatusType[] = [
'UNPAID',
'PAUSED',
'INCOMPLETE',
'INCOMPLETE_EXPIRED',
'CANCELED',
];
const allowed: SubscriptionStatusType[] = ['ACTIVE', 'TRIALING', 'PAST_DUE'];
describe('Current subscription entitlement', () => {
it.each(denied)('%s removes paid benefits without erasing billing plan', status => {
const subscription = subscriptionFor(status);
expect(subscription.hasFeature('api_access')).toBe(false);
expect(subscription.maxShipmentsPerYear).toBe(SubscriptionPlan.bronze().maxShipmentsPerYear);
expect(subscription.bookingFeeEur).toBe(SubscriptionPlan.bronze().bookingFeeEur);
expect(subscription.plan.value).toBe('GOLD');
});
it.each(allowed)('%s retains paid benefits', status => {
const subscription = subscriptionFor(status);
expect(subscription.hasFeature('api_access')).toBe(true);
expect(subscription.maxShipmentsPerYear).toBe(SubscriptionPlan.gold().maxShipmentsPerYear);
});
const setup = (
subscription: Subscription | null,
role = 'MANAGER',
planFeatures = ['user_management']
) => {
const findByOrganizationId = jest.fn().mockResolvedValue(subscription);
const reflector = { getAllAndOverride: jest.fn().mockReturnValue(['user_management']) };
const guard = new FeatureFlagGuard(
reflector as unknown as Reflector,
{ findByOrganizationId } as unknown as SubscriptionRepository
);
const context = {
getHandler: () => undefined,
getClass: () => undefined,
switchToHttp: () => ({
getRequest: () => ({ user: { organizationId: 'org', role, planFeatures } }),
}),
} as unknown as ExecutionContext;
return { guard, context, findByOrganizationId };
};
it.each(denied)('%s cannot be bypassed by stale token features', async status => {
const { guard, context } = setup(subscriptionFor(status));
await expect(guard.canActivate(context)).rejects.toBeInstanceOf(ForbiddenException);
});
it('denies a deleted subscription even with paid token features', async () => {
const { guard, context } = setup(null);
await expect(guard.canActivate(context)).rejects.toBeInstanceOf(ForbiddenException);
});
it.each(allowed)('%s allows current rights despite an old Bronze token', async status => {
const { guard, context } = setup(subscriptionFor(status), 'MANAGER', []);
await expect(guard.canActivate(context)).resolves.toBe(true);
});
it('preserves the platform ADMIN override', async () => {
const { guard, context, findByOrganizationId } = setup(null, 'ADMIN');
await expect(guard.canActivate(context)).resolves.toBe(true);
expect(findByOrganizationId).not.toHaveBeenCalled();
});
it('fails closed when current rights cannot be loaded', async () => {
const { guard, context, findByOrganizationId } = setup(subscriptionFor('ACTIVE'));
findByOrganizationId.mockRejectedValue(new Error('unavailable'));
await expect(guard.canActivate(context)).rejects.toBeInstanceOf(ForbiddenException);
});
});

View File

@ -1,3 +1,4 @@
import { safeRequestRoute } from '../logging/safe-http-log';
/** /**
* Performance Monitoring Interceptor * Performance Monitoring Interceptor
* *
@ -15,7 +16,8 @@ export class PerformanceMonitoringInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> { intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest(); const request = context.switchToHttp().getRequest();
const { method, url, user } = request; const { method, user } = request;
const url = safeRequestRoute(request);
const startTime = Date.now(); const startTime = Date.now();
return next.handle().pipe( return next.handle().pipe(
@ -39,10 +41,7 @@ export class PerformanceMonitoringInterceptor implements NestInterceptor {
const duration = Date.now() - startTime; const duration = Date.now() - startTime;
// Log error // Log error
this.logger.error( this.logger.error(`Request error: ${method} ${url} (${duration}ms)`);
`Request error: ${method} ${url} (${duration}ms) - ${error.message}`,
error.stack
);
// Capture exception in Sentry // Capture exception in Sentry
Sentry.withScope(scope => { Sentry.withScope(scope => {
@ -52,7 +51,7 @@ export class PerformanceMonitoringInterceptor implements NestInterceptor {
userId: user?.sub, userId: user?.sub,
duration, duration,
}); });
Sentry.captureException(error); Sentry.captureException(new Error('Request failed; sensitive error details omitted'));
}); });
throw error; throw error;

View File

@ -0,0 +1,103 @@
import pino from 'pino';
import { Logger, ArgumentsHost, NotFoundException } from '@nestjs/common';
import { safeHttpSerializers, safeRequestRoute } from './safe-http-log';
import { UnhandledExceptionFilter } from '../filters/unhandled-exception.filter';
import { CsvBookingService } from '../services/csv-booking.service';
import { InvitationsController } from '../controllers/invitations.controller';
import { TypeOrmCsvBookingRepository } from '@infrastructure/persistence/typeorm/repositories/csv-booking.repository';
const secret = 'test-secret-not-for-logs';
describe('Capability-safe logs', () => {
afterEach(() => jest.restoreAllMocks());
it('serializes real Pino events without request, response or driver secrets', () => {
let output = '';
const logger = pino(
{ serializers: safeHttpSerializers },
{
write: (line: string) => {
output += line;
},
}
);
logger.error(
{
req: {
method: 'GET',
url: `/api/v1/invitations/verify/${secret}?password=${secret}`,
headers: { cookie: secret, referer: secret },
params: { token: secret },
body: { password: secret },
raw: { route: { path: '/api/v1/invitations/verify/:token' } },
},
res: { statusCode: 500, headers: { 'set-cookie': secret } },
err: Object.assign(new Error(secret), {
query: secret,
parameters: [secret],
cause: new Error(secret),
}),
},
'request failed'
);
expect(output).not.toContain(secret);
expect(JSON.parse(output)).toMatchObject({
req: { method: 'GET', route: '/api/v1/invitations/verify/:token' },
res: { statusCode: 500 },
});
});
it('does not fall back to a raw or encoded path on an unmatched request', () => {
expect(safeRequestRoute({ url: `/api/v1/%69nvitations/verify/${secret}` })).toBe(
'[unmatched route]'
);
});
it('keeps correlation without raw exception details or URL in the global filter', () => {
const errorLog = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
const json = jest.fn();
const status = jest.fn().mockReturnValue({ json });
const host = {
switchToHttp: () => ({
getRequest: () => ({ method: 'GET', url: `/${secret}`, headers: {} }),
getResponse: () => ({ status }),
}),
} as unknown as ArgumentsHost;
new UnhandledExceptionFilter({ translate: () => 'Please retry' } as never).catch(
new Error(secret),
host
);
expect(JSON.stringify(errorLog.mock.calls)).not.toContain(secret);
expect(JSON.stringify(json.mock.calls)).not.toContain(secret);
expect(json.mock.calls[0][0].reference).toBeTruthy();
});
it('does not log tokens across controller, service and repository lookup paths', async () => {
const logs = jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
const orm = { findOne: jest.fn().mockResolvedValue(null) };
const repository = new TypeOrmCsvBookingRepository(orm as never);
const service = new CsvBookingService(
repository,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never
);
for (const call of [
() => service.getBookingByToken(secret),
() => service.acceptBooking(secret),
() => service.rejectBooking(secret),
]) {
await expect(call()).rejects.toBeInstanceOf(NotFoundException);
}
await expect(service.getBookingByToken(secret)).rejects.not.toThrow(secret);
const controller = new InvitationsController({
verifyInvitation: jest.fn().mockRejectedValue(new NotFoundException('not found')),
} as never);
await expect(controller.verifyInvitation(secret)).rejects.toBeInstanceOf(NotFoundException);
expect(JSON.stringify(logs.mock.calls)).not.toContain(secret);
expect(orm.findOne).toHaveBeenCalledWith({ where: { confirmationToken: secret } });
});
});

View File

@ -0,0 +1,24 @@
/** Log server-owned routing metadata, never credentials carried by URLs or headers. */
export function safeRequestRoute(request: unknown): string {
if (!request || typeof request !== 'object') return '[unmatched route]';
const req = request as { route?: { path?: unknown }; raw?: { route?: { path?: unknown } } };
const path = req.route?.path ?? req.raw?.route?.path;
return typeof path === 'string' ? path : '[unmatched route]';
}
export const safeHttpSerializers = {
req(request: {
method?: string;
route?: { path?: unknown };
raw?: { route?: { path?: unknown } };
}) {
return { method: request.method, route: safeRequestRoute(request) };
},
res(response: { statusCode?: number }) {
return { statusCode: response.statusCode };
},
err(_error: unknown) {
// Driver/SMTP errors can contain SQL parameters, tokens, headers or message bodies.
return { type: 'Error', message: 'Request failed; sensitive error details omitted' };
},
};

View File

@ -94,7 +94,7 @@ describe('knowledgeCapabilities', () => {
id: 'w1', id: 'w1',
locale: 'fr', locale: 'fr',
...page, ...page,
authorUserId: 'someone', authorUserId: 'user',
authorOrganizationId: 'org', authorOrganizationId: 'org',
}); });
contributions.findByTitle.mockResolvedValue(existing); contributions.findByTitle.mockResolvedValue(existing);
@ -111,26 +111,17 @@ describe('knowledgeCapabilities', () => {
expect(result).toMatchObject({ status: 'pending_review' }); expect(result).toMatchObject({ status: 'pending_review' });
}); });
it('sends a revised page back through review', async () => { it('does not withdraw a published page even for its original author', async () => {
const published = WikiContribution.create({ const published = WikiContribution.create({
id: 'w1', id: 'w1',
locale: 'fr', locale: 'fr',
...page, ...page,
authorUserId: 'someone', authorUserId: actor.id,
authorOrganizationId: 'org', authorOrganizationId: actor.organizationId,
}).publish('admin'); }).publish('admin');
contributions.findByTitle.mockResolvedValue(published); contributions.findByTitle.mockResolvedValue(published);
await expect(invoke(contribute(), page)).rejects.toThrow(CapabilityInputError);
await invoke(contribute(), { expect(contributions.save).not.toHaveBeenCalled();
...page,
body: `${BODY} Le manifeste est déposé par le transitaire.`,
});
// Sans cela, la validation d'un administrateur porterait sur un texte que
// l'assistant a remplace depuis.
const saved: WikiContribution = contributions.save.mock.calls[0][0];
expect(saved.status).toBe(WikiContributionStatus.PENDING);
expect(saved.reviewedByUserId).toBeUndefined();
}); });
it('refuses content that recommends FCL, with a message the assistant can relay', async () => { it('refuses content that recommends FCL, with a message the assistant can relay', async () => {

View File

@ -1,9 +1,13 @@
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { TradeRetrievalPort } from '@domain/ports/out/trade-assistant.port'; import { TradeRetrievalPort } from '@domain/ports/out/trade-assistant.port';
import { WikiContributionRepository } from '@domain/ports/out/wiki-contribution.repository'; import {
WikiContributionConflict,
WikiContributionRepository,
} from '@domain/ports/out/wiki-contribution.repository';
import { import {
WikiContribution, WikiContribution,
WikiContributionRejected, WikiContributionRejected,
WikiContributionStatus,
} from '@domain/entities/wiki-contribution.entity'; } from '@domain/entities/wiki-contribution.entity';
import { import {
WIKI_REFUSAL_MESSAGES, WIKI_REFUSAL_MESSAGES,
@ -105,7 +109,7 @@ function contributeWikiPage(
return { return {
policy: { name: 'contribute_wiki_page', scope: 'write' }, policy: { name: 'contribute_wiki_page', scope: 'write' },
description: description:
"Propose au wiki Xpeditis une page d'information générale sur le transport international, quand la documentation ne couvre pas le sujet. La page part en relecture : elle n'est publiée qu'après validation par un administrateur. Réservé au savoir durable et valable pour tous les clients : jamais un cas client, un dossier, un tarif, un contenu recommandant le FCL, ni un sujet de transport national. Met à jour la proposition existante si le titre est déjà pris.", "Propose au wiki Xpeditis une page d'information générale sur le transport international, quand la documentation ne couvre pas le sujet. La page part en relecture : elle n'est publiée qu'après validation par un administrateur. Réservé au savoir durable et valable pour tous les clients : jamais un cas client, un dossier, un tarif, un contenu recommandant le FCL, ni un sujet de transport national. Met à jour uniquement votre propre proposition non publiée si le titre est déjà pris.",
inputSchema: { inputSchema: {
type: 'object', type: 'object',
properties: { properties: {
@ -152,6 +156,17 @@ function contributeWikiPage(
const existing = await contributions.findByTitle(locale, topic, title); const existing = await contributions.findByTitle(locale, topic, title);
if (
existing &&
(existing.authorUserId !== actor.id ||
existing.authorOrganizationId !== actor.organizationId ||
existing.status === WikiContributionStatus.PUBLISHED)
) {
throw new CapabilityInputError(
'Cette page ne peut pas être modifiée par cette contribution.'
);
}
// Le doublon n'est teste que pour une page nouvelle : reviser un // Le doublon n'est teste que pour une page nouvelle : reviser un
// complement existant se heurterait sinon a ce complement lui-meme. // complement existant se heurterait sinon a ce complement lui-meme.
if (!existing) { if (!existing) {
@ -178,7 +193,17 @@ function contributeWikiPage(
}) })
); );
const saved = await contributions.save(page); let saved: WikiContribution;
try {
saved = await contributions.save(page, actor);
} catch (error) {
if (error instanceof WikiContributionConflict) {
throw new CapabilityInputError(
'La proposition a changé ou ce titre est déjà utilisé. Relisez la page avant de réessayer.'
);
}
throw error;
}
return { return {
// Le resultat dit l'etat reel, pas l'intention : le modele annonce une // Le resultat dit l'etat reel, pas l'intention : le modele annonce une
// proposition en attente, jamais une page publiee. // proposition en attente, jamais une page publiee.

View File

@ -0,0 +1,69 @@
import { Subscription } from '@domain/entities/subscription.entity';
import { SubscriptionPlan } from '@domain/value-objects/subscription-plan.vo';
import {
SubscriptionStatus,
SubscriptionStatusType,
} from '@domain/value-objects/subscription-status.vo';
import { McpController } from './mcp.controller';
import { CapabilityRegistry } from './capability.registry';
import { SubscriptionService } from '../services/subscription.service';
// Real registry and account capability: stale client claims must not be echoed as rights.
describe('MCP current entitlement', () => {
it.each<[SubscriptionStatusType, string, string]>([
['UNPAID', 'MANAGER', 'BRONZE'],
['PAUSED', 'MANAGER', 'BRONZE'],
['ACTIVE', 'MANAGER', 'GOLD'],
['TRIALING', 'MANAGER', 'GOLD'],
['PAST_DUE', 'MANAGER', 'GOLD'],
['UNPAID', 'ADMIN', 'PLATINIUM'],
])('%s resolves live rights for %s', async (status, role, expected) => {
const subscription = Subscription.create({
id: 'sub',
organizationId: 'org',
plan: SubscriptionPlan.gold(),
}).updateStatus(SubscriptionStatus.create(status));
const subscriptions = { getOrCreateSubscription: jest.fn().mockResolvedValue(subscription) };
const registry = new CapabilityRegistry(
{} as never,
{} as never,
{} as never,
subscriptions as unknown as SubscriptionService,
{} as never,
{} as never,
{ log: jest.fn().mockResolvedValue(undefined) } as never
);
const controller = new McpController(registry, subscriptions as unknown as SubscriptionService);
const user = {
id: 'user',
organizationId: 'org',
email: 'test@example.test',
firstName: 'Test',
lastName: 'User',
role,
plan: 'GOLD',
};
const result = await controller.rpc(user, {
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name: 'whoami' },
});
expect(result).toMatchObject({
result: {
isError: false,
content: [
{
type: 'text',
text: JSON.stringify(
{ userId: 'user', organizationId: 'org', role, plan: expected },
null,
2
),
},
],
},
});
expect(subscriptions.getOrCreateSubscription).toHaveBeenCalledWith('org');
});
});

View File

@ -155,28 +155,17 @@ export class McpController {
/** /**
* Identite de l'appelant, completee de son offre. * Identite de l'appelant, completee de son offre.
* *
* Une cle API porte deja l'offre ; un jeton JWT ne la porte pas, elle est * L'offre est relue a chaque appel pour appliquer les suspensions et les
* alors lue sur l'abonnement. Sans cette resolution, un utilisateur connecte * changements de droits, meme si un jeton porte encore une ancienne offre.
* a l'application serait traite comme un compte Bronze.
*/ */
private async actorOf(user: UserPayload & { plan?: string }): Promise<CapabilityActor> { private async actorOf(user: UserPayload & { plan?: string }): Promise<CapabilityActor> {
if (user.plan) {
return {
id: user.id,
organizationId: user.organizationId,
role: user.role,
email: user.email,
plan: user.plan,
};
}
const subscription = await this.subscriptions.getOrCreateSubscription(user.organizationId); const subscription = await this.subscriptions.getOrCreateSubscription(user.organizationId);
return { return {
id: user.id, id: user.id,
organizationId: user.organizationId, organizationId: user.organizationId,
role: user.role, role: user.role,
email: user.email, email: user.email,
plan: subscription.plan.value, plan: subscription.accessPlan.value,
}; };
} }
} }

View File

@ -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],

View File

@ -0,0 +1,133 @@
import { ServiceUnavailableException } from '@nestjs/common';
import { CsvBookingService } from './csv-booking.service';
import { CreateCsvBookingDto } from '../dto/csv-booking.dto';
describe('Booking fee failure boundary', () => {
const setup = () => {
const subscriptionService = { getOrCreateSubscription: jest.fn() };
const booking = {
id: 'booking',
organizationId: 'org',
accept: jest.fn(),
applyBookingFee: jest.fn(),
};
const repo = {
findByToken: jest.fn().mockResolvedValue(booking),
repository: { findOne: jest.fn().mockResolvedValue(null) },
create: jest.fn(),
update: jest.fn(),
};
const service = new CsvBookingService(
repo as never,
{} as never,
{} as never,
{} as never,
{} as never,
subscriptionService as never,
{} as never
);
const upload = jest
.spyOn(service as unknown as { uploadDocuments: () => Promise<unknown[]> }, 'uploadDocuments')
.mockResolvedValue([]);
return { service, subscriptionService, repo, booking, upload };
};
it('refuses creation before uploading or saving when the fee is unknown', async () => {
const { service, subscriptionService, repo, upload } = setup();
subscriptionService.getOrCreateSubscription.mockRejectedValue(
new Error('dependency unavailable')
);
await expect(
service.createBooking({} as CreateCsvBookingDto, [{} as Express.Multer.File], 'user', 'org')
).rejects.toBeInstanceOf(ServiceUnavailableException);
expect(upload).not.toHaveBeenCalled();
expect(repo.create).not.toHaveBeenCalled();
});
it('does not accept or save a booking when the fee lookup fails', async () => {
const { service, subscriptionService, repo, booking } = setup();
subscriptionService.getOrCreateSubscription.mockRejectedValue(
new Error('dependency unavailable')
);
await expect(service.acceptBooking('test-token')).rejects.toBeInstanceOf(
ServiceUnavailableException
);
expect(booking.accept).not.toHaveBeenCalled();
expect(repo.update).not.toHaveBeenCalled();
});
it.each([
[15, 'QUOTE'],
[-1, 'PENDING'],
])('preserves creation for a known fee %s', async (fee, expectedStatus) => {
const { service, subscriptionService, repo, upload } = setup();
subscriptionService.getOrCreateSubscription.mockResolvedValue({ bookingFeeEur: fee });
upload.mockResolvedValue([
{
id: 'doc',
type: 'OTHER',
fileName: 'test.pdf',
filePath: 'test',
mimeType: 'application/pdf',
size: 1,
uploadedAt: new Date(),
},
]);
repo.create.mockImplementation(async value => value);
const mail = jest
.spyOn(
service as unknown as { sendCarrierBookingRequest: () => Promise<void> },
'sendCarrierBookingRequest'
)
.mockResolvedValue(undefined);
jest
.spyOn(
service as unknown as { notifyBookingRequestSent: () => Promise<void> },
'notifyBookingRequestSent'
)
.mockResolvedValue(undefined);
const result = await service.createBooking(
{
carrierName: 'Carrier',
carrierEmail: 'carrier@example.test',
origin: 'FRLEH',
destination: 'CNSHA',
volumeCBM: 1,
weightKG: 100,
palletCount: 1,
priceUSD: 100,
priceEUR: 90,
primaryCurrency: 'EUR',
transitDays: 10,
containerType: 'LCL',
} as CreateCsvBookingDto,
[{} as Express.Multer.File],
'user',
'org'
);
expect(result.status).toBe(expectedStatus);
expect(result.commissionAmountEur).toBe(fee === -1 ? 0 : fee);
expect(repo.create).toHaveBeenCalledTimes(1);
expect(mail).toHaveBeenCalledTimes(fee === -1 ? 1 : 0);
});
it.each([
[15, 15],
[10, 10],
[5, 5],
[-1, 0],
[0, 0],
])('preserves fee %s as %s', async (fee, expected) => {
const { service, subscriptionService } = setup();
subscriptionService.getOrCreateSubscription.mockResolvedValue({ bookingFeeEur: fee });
await expect(service['resolveBookingFeeEur']('org')).resolves.toBe(expected);
});
it.each([undefined, NaN, Infinity, -2])('rejects an invalid fee %s', async fee => {
const { service, subscriptionService } = setup();
subscriptionService.getOrCreateSubscription.mockResolvedValue({ bookingFeeEur: fee });
await expect(service['resolveBookingFeeEur']('org')).rejects.toBeInstanceOf(
ServiceUnavailableException
);
});
});

View File

@ -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');
});
});

View File

@ -5,6 +5,7 @@ import {
BadRequestException, BadRequestException,
Inject, Inject,
UnauthorizedException, UnauthorizedException,
ServiceUnavailableException,
} from '@nestjs/common'; } from '@nestjs/common';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import * as argon2 from 'argon2'; import * as argon2 from 'argon2';
@ -151,6 +152,9 @@ export class CsvBookingService {
throw new BadRequestException('At least one document is required'); throw new BadRequestException('At least one document is required');
} }
// Resolve pricing before uploads or any persistent side effect.
const bookingFeeEur = await this.resolveBookingFeeEur(organizationId);
// Generate unique confirmation token and booking number // Generate unique confirmation token and booking number
const confirmationToken = uuidv4(); const confirmationToken = uuidv4();
const bookingId = uuidv4(); const bookingId = uuidv4();
@ -166,7 +170,6 @@ export class CsvBookingService {
// Flat per-booking service fee (forfait par booking) based on the org's plan. // Flat per-booking service fee (forfait par booking) based on the org's plan.
// A fee <= 0 (e.g. Platinium "sur mesure") means no automatic charge: the // A fee <= 0 (e.g. Platinium "sur mesure") means no automatic charge: the
// booking skips the payment gate and the carrier is notified immediately. // booking skips the payment gate and the carrier is notified immediately.
const bookingFeeEur = await this.resolveBookingFeeEur(organizationId);
const requiresPayment = bookingFeeEur > 0; const requiresPayment = bookingFeeEur > 0;
const initialStatus = requiresPayment ? CsvBookingStatus.QUOTE : CsvBookingStatus.PENDING; const initialStatus = requiresPayment ? CsvBookingStatus.QUOTE : CsvBookingStatus.PENDING;
@ -246,17 +249,20 @@ export class CsvBookingService {
/** /**
* Resolve the flat per-booking fee (forfait par booking) for an organization * Resolve the flat per-booking fee (forfait par booking) for an organization
* from its subscription plan. Returns the plan's bookingFeeEur, or 0 when the * from its subscription plan. Returns the plan's bookingFeeEur, or 0 when the
* plan has a custom fee (-1, e.g. Platinium "sur mesure") or on error — such * plan has a custom fee (-1, e.g. Platinium "sur mesure").
* bookings are not auto-charged. * An unknown fee must never be interpreted as a free booking.
*/ */
private async resolveBookingFeeEur(organizationId: string): Promise<number> { private async resolveBookingFeeEur(organizationId: string): Promise<number> {
try { try {
const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId); const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId);
const fee = subscription.plan.bookingFeeEur; const fee = subscription.bookingFeeEur;
if (!Number.isFinite(fee) || (fee < 0 && fee !== -1)) {
throw new Error('Invalid booking fee');
}
return fee > 0 ? fee : 0; return fee > 0 ? fee : 0;
} catch (error: any) { } catch {
this.logger.error(`Failed to resolve booking fee: ${error?.message}`); this.logger.error('Failed to resolve booking fee');
return 0; throw new ServiceUnavailableException('Booking fee unavailable. Please retry later.');
} }
} }
@ -409,7 +415,7 @@ export class CsvBookingService {
}); });
this.logger.log(`Email sent to carrier: ${booking.carrierEmail}`); this.logger.log(`Email sent to carrier: ${booking.carrierEmail}`);
} catch (error: any) { } catch (error: any) {
this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack); this.logger.error('Failed to send email to carrier');
} }
} }
@ -491,7 +497,7 @@ export class CsvBookingService {
this.logger.log(`Admin notification email sent to: ${adminEmails.join(', ')}`); this.logger.log(`Admin notification email sent to: ${adminEmails.join(', ')}`);
} }
} catch (error: any) { } catch (error: any) {
this.logger.error(`Failed to send admin notification email: ${error?.message}`, error?.stack); this.logger.error('Failed to send admin notification email');
} }
// In-app notification for the user // In-app notification for the user
@ -617,11 +623,7 @@ export class CsvBookingService {
`Email sent to carrier after bank transfer validation: ${booking.carrierEmail}` `Email sent to carrier after bank transfer validation: ${booking.carrierEmail}`
); );
} catch (error: any) { } catch (error: any) {
this.logger.error( this.logger.error('Failed to send email to carrier');
`Bank transfer validated for booking ${bookingId} but the carrier email to ` +
`${booking.carrierEmail} failed: ${error?.message}`,
error?.stack
);
} }
// In-app notification for the user // In-app notification for the user
@ -702,7 +704,7 @@ export class CsvBookingService {
const booking = await this.csvBookingRepository.findByToken(token); const booking = await this.csvBookingRepository.findByToken(token);
if (!booking) { if (!booking) {
throw new NotFoundException(`Booking with token ${token} not found`); throw new NotFoundException('Booking not found');
} }
return this.toResponseDto(booking); return this.toResponseDto(booking);
@ -716,7 +718,7 @@ export class CsvBookingService {
token: string, token: string,
password?: string password?: string
): Promise<CarrierDocumentsResponseDto> { ): Promise<CarrierDocumentsResponseDto> {
this.logger.log(`Getting documents for carrier with token: ${token}`); this.logger.log('Getting documents for carrier');
// Get ORM entity to access passwordHash // Get ORM entity to access passwordHash
const ormBooking = await this.csvBookingRepository['repository'].findOne({ const ormBooking = await this.csvBookingRepository['repository'].findOne({
@ -886,7 +888,7 @@ export class CsvBookingService {
* Accept a booking request * Accept a booking request
*/ */
async acceptBooking(token: string): Promise<CsvBookingResponseDto> { async acceptBooking(token: string): Promise<CsvBookingResponseDto> {
this.logger.log(`Accepting booking with token: ${token}`); this.logger.log('Accepting booking');
const booking = await this.csvBookingRepository.findByToken(token); const booking = await this.csvBookingRepository.findByToken(token);
@ -899,11 +901,9 @@ export class CsvBookingService {
where: { confirmationToken: token }, where: { confirmationToken: token },
}); });
// Accept the booking (domain logic validates status) // Resolve pricing before mutating the booking.
booking.accept();
// Apply the flat per-booking service fee (forfait par booking) from the org's plan
const bookingFeeEur = await this.resolveBookingFeeEur(booking.organizationId); const bookingFeeEur = await this.resolveBookingFeeEur(booking.organizationId);
booking.accept();
booking.applyBookingFee(bookingFeeEur); booking.applyBookingFee(bookingFeeEur);
this.logger.log( this.logger.log(
`Booking fee applied: ${bookingFeeEur > 0 ? `${bookingFeeEur}€ (flat)` : 'none (custom)'} on booking ${booking.id}` `Booking fee applied: ${bookingFeeEur > 0 ? `${bookingFeeEur}€ (flat)` : 'none (custom)'} on booking ${booking.id}`
@ -933,7 +933,7 @@ export class CsvBookingService {
}); });
this.logger.log(`Document access email sent to carrier: ${booking.carrierEmail}`); this.logger.log(`Document access email sent to carrier: ${booking.carrierEmail}`);
} catch (error: any) { } catch (error: any) {
this.logger.error(`Failed to send document access email: ${error?.message}`, error?.stack); this.logger.error('Failed to send document access email');
} }
// Create notification for user // Create notification for user
@ -960,7 +960,7 @@ export class CsvBookingService {
* Reject a booking request * Reject a booking request
*/ */
async rejectBooking(token: string, reason?: string): Promise<CsvBookingResponseDto> { async rejectBooking(token: string, reason?: string): Promise<CsvBookingResponseDto> {
this.logger.log(`Rejecting booking with token: ${token}`); this.logger.log('Rejecting booking');
const booking = await this.csvBookingRepository.findByToken(token); const booking = await this.csvBookingRepository.findByToken(token);
@ -1411,10 +1411,7 @@ export class CsvBookingService {
}); });
this.logger.log(`New documents notification sent to carrier: ${booking.carrierEmail}`); this.logger.log(`New documents notification sent to carrier: ${booking.carrierEmail}`);
} catch (error: any) { } catch (error: any) {
this.logger.error( this.logger.error('Failed to send new documents notification');
`Failed to send new documents notification: ${error?.message}`,
error?.stack
);
} }
} }
@ -1618,7 +1615,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,

View File

@ -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();
}
});
});

View File

@ -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;
} }
} }

View File

@ -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);
});
});

View File

@ -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}`);
} }

View File

@ -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 });
});
});

View File

@ -0,0 +1,115 @@
import { ConfigService } from '@nestjs/config';
import { SubscriptionService } from './subscription.service';
import { Subscription } from '@domain/entities/subscription.entity';
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,
StripeCheckoutSessionData,
StripeSubscriptionData,
} from '@domain/ports/out/stripe.port';
import { SubscriptionOverviewResponseDto } from '../dto/subscription.dto';
describe('Stripe checkout organization binding', () => {
let subscription: Subscription;
let session: StripeCheckoutSessionData;
let stripeData: StripeSubscriptionData;
let save: jest.Mock;
let getSubscription: jest.Mock;
let service: SubscriptionService;
beforeEach(() => {
subscription = Subscription.create({ id: 'local-sub', organizationId: 'org-caller' });
session = {
sessionId: 'cs_fixture',
customerId: 'cus_fixture',
subscriptionId: 'sub_fixture',
status: 'complete',
metadata: { organizationId: 'org-caller' },
};
stripeData = {
subscriptionId: 'sub_fixture',
customerId: 'cus_fixture',
status: 'active',
planId: 'price_fixture',
currentPeriodStart: new Date(),
currentPeriodEnd: new Date(),
cancelAtPeriodEnd: false,
};
// No local row owns the Stripe subscription yet: models checkout before webhook delivery.
save = jest.fn(async (value: Subscription) => {
subscription = value;
return value;
});
getSubscription = jest.fn(async () => stripeData);
service = new SubscriptionService(
{ findByOrganizationId: async () => subscription, save } as unknown as SubscriptionRepository,
{ countActiveBySubscriptionIdExcludingAdmins: async () => 0 } as unknown as LicenseRepository,
{} as OrganizationRepository,
{} as UserRepository,
{
getCheckoutSession: async () => session,
getSubscription,
mapPriceIdToPlan: () => 'GOLD',
} as unknown as StripePort,
new ConfigService()
);
jest
.spyOn(service, 'getSubscriptionOverview')
.mockResolvedValue({} as SubscriptionOverviewResponseDto);
});
it.each(['org-victim', undefined])(
'rejects a checkout whose organization is %j before fetching or saving its subscription',
async owner => {
session.metadata = owner ? { organizationId: owner } : {};
await expect(service.syncFromStripe('org-caller', session.sessionId)).rejects.toMatchObject({
status: 403,
});
expect(getSubscription).not.toHaveBeenCalled();
expect(save).not.toHaveBeenCalled();
}
);
it('preserves checkout synchronization for its authenticated organization', async () => {
await service.syncFromStripe('org-caller', session.sessionId);
expect(save).toHaveBeenCalledTimes(1);
expect(subscription.stripeSubscriptionId).toBe('sub_fixture');
expect(subscription.stripeCustomerId).toBe('cus_fixture');
expect(subscription.plan.value).toBe('GOLD');
});
it('does not accept a foreign checkout merely because the customer matches', async () => {
subscription = subscription.updateStripeCustomerId('cus_fixture');
session.metadata = { organizationId: 'org-victim' };
await expect(service.syncFromStripe('org-caller', session.sessionId)).rejects.toMatchObject({
status: 403,
});
expect(save).not.toHaveBeenCalled();
});
it('permits an owned upgrade to replace the existing Stripe subscription ID', async () => {
subscription = subscription.updateStripeCustomerId('cus_fixture').updateStripeSubscription({
stripeSubscriptionId: 'sub_old',
currentPeriodStart: new Date(),
currentPeriodEnd: new Date(),
cancelAtPeriodEnd: false,
});
await service.syncFromStripe('org-caller', session.sessionId);
expect(subscription.stripeSubscriptionId).toBe('sub_fixture');
expect(save).toHaveBeenCalledTimes(1);
});
it('preserves sessionless refresh of an already linked subscription', async () => {
subscription = subscription.updateStripeCustomerId('cus_fixture').updateStripeSubscription({
stripeSubscriptionId: 'sub_fixture',
currentPeriodStart: new Date(),
currentPeriodEnd: new Date(),
cancelAtPeriodEnd: false,
});
await service.syncFromStripe('org-caller');
expect(getSubscription).toHaveBeenCalledWith('sub_fixture');
expect(save).toHaveBeenCalledTimes(1);
});
});

View File

@ -4,7 +4,14 @@
* Business logic for subscription and license management. * Business logic for subscription and license management.
*/ */
import { Injectable, Inject, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; import {
Injectable,
Inject,
Logger,
NotFoundException,
BadRequestException,
ForbiddenException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { import {
@ -90,7 +97,7 @@ export class SubscriptionService {
// ADMIN users always have PLATINIUM plan with no expiration. // ADMIN users always have PLATINIUM plan with no expiration.
// La regle vit dans le domaine : l'assistant la lit au meme endroit. // La regle vit dans le domaine : l'assistant la lit au meme endroit.
const isAdmin = userRole === PLATFORM_ADMIN_ROLE; const isAdmin = userRole === PLATFORM_ADMIN_ROLE;
const effectivePlan = resolveEffectivePlan(userRole, subscription.plan); const effectivePlan = resolveEffectivePlan(userRole, subscription.accessPlan);
const maxLicenses = effectivePlan.maxLicenses; const maxLicenses = effectivePlan.maxLicenses;
const availableLicenses = effectivePlan.isUnlimited() const availableLicenses = effectivePlan.isUnlimited()
? -1 ? -1
@ -280,6 +287,9 @@ export class SubscriptionService {
const checkoutSession = await this.stripeAdapter.getCheckoutSession(sessionId); const checkoutSession = await this.stripeAdapter.getCheckoutSession(sessionId);
if (checkoutSession) { if (checkoutSession) {
if (checkoutSession.metadata?.organizationId !== organizationId) {
throw new ForbiddenException('Checkout session does not belong to this organization');
}
this.logger.log( this.logger.log(
`Checkout session found: subscriptionId=${checkoutSession.subscriptionId}, customerId=${checkoutSession.customerId}, status=${checkoutSession.status}` `Checkout session found: subscriptionId=${checkoutSession.subscriptionId}, customerId=${checkoutSession.customerId}, status=${checkoutSession.status}`
); );
@ -608,12 +618,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);

View File

@ -10,7 +10,7 @@ import {
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { IsOptional, IsString, Length } from 'class-validator'; import { IsInt, Min, IsOptional, IsString, Length } from 'class-validator';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
import { Roles } from '../decorators/roles.decorator'; import { Roles } from '../decorators/roles.decorator';
@ -21,6 +21,10 @@ import { WikiReviewService, WikiReviewer } from './wiki-review.service';
const trim = ({ value }: { value: unknown }) => (typeof value === 'string' ? value.trim() : value); const trim = ({ value }: { value: unknown }) => (typeof value === 'string' ? value.trim() : value);
export class PublishWikiContributionDto { export class PublishWikiContributionDto {
@IsInt()
@Min(1)
expectedVersion: number;
/** Correction de l'intitulé de section. Absent : celui proposé est conservé. */ /** Correction de l'intitulé de section. Absent : celui proposé est conservé. */
@IsOptional() @IsOptional()
@Transform(trim) @Transform(trim)
@ -37,6 +41,10 @@ export class PublishWikiContributionDto {
} }
export class RejectWikiContributionDto { export class RejectWikiContributionDto {
@IsInt()
@Min(1)
expectedVersion: number;
/** Motif, conservé avec la page pour relire les refus. */ /** Motif, conservé avec la page pour relire les refus. */
@IsOptional() @IsOptional()
@Transform(trim) @Transform(trim)
@ -85,7 +93,7 @@ export class WikiReviewController {
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@Body() dto: RejectWikiContributionDto @Body() dto: RejectWikiContributionDto
) { ) {
return this.service.reject(reviewerOf(user), id, dto.note); return this.service.reject(reviewerOf(user), id, dto.expectedVersion, dto.note);
} }
} }

View File

@ -0,0 +1,52 @@
import { ConflictException } from '@nestjs/common';
import { WikiContribution, WikiContributionProps } from '@domain/entities/wiki-contribution.entity';
import { knowledgeCapabilities } from '../mcp/capabilities/knowledge.capabilities';
import { WikiReviewService } from './wiki-review.service';
const body =
'Le manifeste de cargaison décrit les marchandises transportées par voie maritime internationale. Le transitaire le transmet aux autorités douanières avant le chargement au port de départ. Les informations doivent être vérifiées pour éviter une immobilisation des marchandises au terminal.';
const draft = () =>
WikiContribution.create({
id: 'page',
locale: 'fr',
topic: 'douanes',
title: 'Le manifeste maritime',
section: 'Transmission du manifeste',
body,
authorUserId: 'author',
authorOrganizationId: 'org',
});
const actor = { id: 'intruder', organizationId: 'other-org', role: 'USER' };
const reviewer = { id: 'admin', organizationId: 'admin-org', email: 'admin@example.test' };
it.each(['pending', 'published'])(
'refuses a foreign %s contribution without saving',
async status => {
const page = status === 'published' ? draft().publish('admin') : draft();
const save = jest.fn(async value => value);
const repo = { findByTitle: jest.fn(async () => page), save };
const cap = knowledgeCapabilities({ search: jest.fn(async () => []) }, repo as never).find(
c => c.policy.name === 'contribute_wiki_page'
)!;
await expect(cap.handler({ ...page.toObject(), language: 'fr' }, actor)).rejects.toThrow();
expect(save).not.toHaveBeenCalled();
}
);
it('refuses publication when the content changed after the review list was loaded', async () => {
const changed = WikiContribution.fromPersistence({
...draft().toObject(),
body: body + ' Le texte a changé.',
version: 2,
} as WikiContributionProps);
const save = jest.fn(async value => value);
const service = new WikiReviewService(
{ findById: jest.fn(async () => changed), save } as never,
{} as never,
{ log: jest.fn() } as never
);
await expect(
service.publish(reviewer, 'page', { expectedVersion: 1 } as never)
).rejects.toBeInstanceOf(ConflictException);
expect(save).not.toHaveBeenCalled();
});

View File

@ -15,7 +15,8 @@ const reviewer = { id: 'admin-1', email: 'admin@xpeditis.com', organizationId: '
const BODY = `La règle du 24 heures impose de transmettre le manifeste de cargaison aux douanes du pays de destination avant le chargement du navire au port d'embarquement. Elle s'applique au transport maritime international et conditionne l'autorisation de charger. Un dépôt tardif expose l'expéditeur à un refus d'embarquement et à une immobilisation du conteneur au terminal.`; const BODY = `La règle du 24 heures impose de transmettre le manifeste de cargaison aux douanes du pays de destination avant le chargement du navire au port d'embarquement. Elle s'applique au transport maritime international et conditionne l'autorisation de charger. Un dépôt tardif expose l'expéditeur à un refus d'embarquement et à une immobilisation du conteneur au terminal.`;
const proposal = () => const proposal = () =>
WikiContribution.create({ WikiContribution.fromPersistence({
...WikiContribution.create({
id: 'w1', id: 'w1',
locale: 'fr', locale: 'fr',
topic: 'douanes', topic: 'douanes',
@ -24,6 +25,8 @@ const proposal = () =>
body: BODY, body: BODY,
authorUserId: 'user', authorUserId: 'user',
authorOrganizationId: 'org', authorOrganizationId: 'org',
}).toObject(),
version: 1,
}); });
describe('WikiReviewService', () => { describe('WikiReviewService', () => {
@ -87,7 +90,7 @@ describe('WikiReviewService', () => {
/* ---------------------------------------------------------------------- */ /* ---------------------------------------------------------------------- */
it('publishes the page and links to it', async () => { it('publishes the page and links to it', async () => {
const view = await service.publish(reviewer, 'w1'); const view = await service.publish(reviewer, 'w1', { expectedVersion: 1 });
const saved: WikiContribution = contributions.save.mock.calls[0][0]; const saved: WikiContribution = contributions.save.mock.calls[0][0];
expect(saved.status).toBe(WikiContributionStatus.PUBLISHED); expect(saved.status).toBe(WikiContributionStatus.PUBLISHED);
@ -98,7 +101,7 @@ describe('WikiReviewService', () => {
it('keeps the reviewer edits instead of the proposed text', async () => { it('keeps the reviewer edits instead of the proposed text', async () => {
const corrected = `${BODY} Le dépôt incombe au transitaire, jamais au destinataire.`; const corrected = `${BODY} Le dépôt incombe au transitaire, jamais au destinataire.`;
await service.publish(reviewer, 'w1', { body: corrected }); await service.publish(reviewer, 'w1', { expectedVersion: 1, body: corrected });
expect(contributions.save.mock.calls[0][0].body).toBe(corrected); expect(contributions.save.mock.calls[0][0].body).toBe(corrected);
}); });
@ -106,14 +109,14 @@ describe('WikiReviewService', () => {
it('refuses an edit that breaks the content policy', async () => { it('refuses an edit that breaks the content policy', async () => {
const advocacy = `${BODY} Au-delà de 15 m³, nous recommandons le FCL.`; const advocacy = `${BODY} Au-delà de 15 m³, nous recommandons le FCL.`;
await expect(service.publish(reviewer, 'w1', { body: advocacy })).rejects.toThrow( await expect(
WikiContributionRejected service.publish(reviewer, 'w1', { expectedVersion: 1, body: advocacy })
); ).rejects.toThrow(WikiContributionRejected);
expect(contributions.save).not.toHaveBeenCalled(); expect(contributions.save).not.toHaveBeenCalled();
}); });
it('keeps a rejected page, with its reason', async () => { it('keeps a rejected page, with its reason', async () => {
const view = await service.reject(reviewer, 'w1', ' Source non vérifiée '); const view = await service.reject(reviewer, 'w1', 1, ' Source non vérifiée ');
const saved: WikiContribution = contributions.save.mock.calls[0][0]; const saved: WikiContribution = contributions.save.mock.calls[0][0];
expect(saved.status).toBe(WikiContributionStatus.REJECTED); expect(saved.status).toBe(WikiContributionStatus.REJECTED);
@ -123,7 +126,7 @@ describe('WikiReviewService', () => {
}); });
it('records who decided what', async () => { it('records who decided what', async () => {
await service.publish(reviewer, 'w1'); await service.publish(reviewer, 'w1', { expectedVersion: 1 });
expect(audit.log).toHaveBeenCalledWith( expect(audit.log).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@ -138,6 +141,8 @@ describe('WikiReviewService', () => {
it('reports an unknown page rather than failing silently', async () => { it('reports an unknown page rather than failing silently', async () => {
contributions.findById.mockResolvedValue(null); contributions.findById.mockResolvedValue(null);
await expect(service.publish(reviewer, 'w1')).rejects.toThrow(NotFoundException); await expect(service.publish(reviewer, 'w1', { expectedVersion: 1 })).rejects.toThrow(
NotFoundException
);
}); });
}); });

View File

@ -1,4 +1,4 @@
import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { ConflictException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { import {
WikiContribution, WikiContribution,
WikiContributionStatus, WikiContributionStatus,
@ -6,6 +6,7 @@ import {
import { import {
WIKI_CONTRIBUTION_REPOSITORY, WIKI_CONTRIBUTION_REPOSITORY,
WikiContributionRepository, WikiContributionRepository,
WikiContributionConflict,
} from '@domain/ports/out/wiki-contribution.repository'; } from '@domain/ports/out/wiki-contribution.repository';
import { AuditAction, AuditStatus } from '@domain/entities/audit-log.entity'; import { AuditAction, AuditStatus } from '@domain/entities/audit-log.entity';
import { USER_REPOSITORY, UserRepository } from '@domain/ports/out/user.repository'; import { USER_REPOSITORY, UserRepository } from '@domain/ports/out/user.repository';
@ -55,9 +56,14 @@ export class WikiReviewService {
}; };
} }
async publish(reviewer: WikiReviewer, id: string, edits?: { section?: string; body?: string }) { async publish(
reviewer: WikiReviewer,
id: string,
edits: { expectedVersion: number; section?: string; body?: string }
) {
const page = await this.find(id); const page = await this.find(id);
const published = await this.contributions.save(page.publish(reviewer.id, edits)); this.assertVersion(page, edits.expectedVersion);
const published = await this.saveReview(page.publish(reviewer.id, edits));
await this.record(reviewer, published, AuditStatus.SUCCESS, 'published'); await this.record(reviewer, published, AuditStatus.SUCCESS, 'published');
this.logger.log(`Wiki contribution "${published.title}" published by ${reviewer.email}`); this.logger.log(`Wiki contribution "${published.title}" published by ${reviewer.email}`);
@ -65,15 +71,41 @@ export class WikiReviewService {
return this.toView(published, { [reviewer.id]: reviewer.email }); return this.toView(published, { [reviewer.id]: reviewer.email });
} }
async reject(reviewer: WikiReviewer, id: string, note?: string) { async reject(reviewer: WikiReviewer, id: string, expectedVersion: number, note?: string) {
const page = await this.find(id); const page = await this.find(id);
const rejected = await this.contributions.save(page.reject(reviewer.id, note)); this.assertVersion(page, expectedVersion);
const rejected = await this.saveReview(page.reject(reviewer.id, note));
await this.record(reviewer, rejected, AuditStatus.SUCCESS, 'rejected'); await this.record(reviewer, rejected, AuditStatus.SUCCESS, 'rejected');
return this.toView(rejected, { [reviewer.id]: reviewer.email }); return this.toView(rejected, { [reviewer.id]: reviewer.email });
} }
private assertVersion(page: WikiContribution, expectedVersion: number): void {
if (
!Number.isSafeInteger(expectedVersion) ||
expectedVersion < 1 ||
page.version !== expectedVersion
) {
throw new ConflictException(
'La proposition a changé. Rechargez-la et relisez-la avant de décider.'
);
}
}
private async saveReview(page: WikiContribution): Promise<WikiContribution> {
try {
return await this.contributions.save(page);
} catch (error) {
if (error instanceof WikiContributionConflict) {
throw new ConflictException(
'La proposition a changé. Rechargez-la et relisez-la avant de décider.'
);
}
throw error;
}
}
private async find(id: string): Promise<WikiContribution> { private async find(id: string): Promise<WikiContribution> {
const page = await this.contributions.findById(id); const page = await this.contributions.findById(id);
if (!page) throw new NotFoundException('Contribution introuvable.'); if (!page) throw new NotFoundException('Contribution introuvable.');
@ -130,6 +162,7 @@ export class WikiReviewService {
private toView(page: WikiContribution, reviewers: Record<string, string>) { private toView(page: WikiContribution, reviewers: Record<string, string>) {
return { return {
id: page.id, id: page.id,
version: page.version,
locale: page.locale, locale: page.locale,
topic: page.topic, topic: page.topic,
title: page.title, title: page.title,

View File

@ -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();

View File

@ -62,35 +62,34 @@ export class Subscription {
}); });
} }
/** /** Current entitlements; keep the persisted plan intact for billing and recovery. */
* Reconstitute from persistence get accessPlan(): SubscriptionPlan {
*/ return this.isActive() ? this.props.plan : SubscriptionPlan.bronze();
/** }
* Check if a specific plan feature is available
*/
hasFeature(feature: import('../value-objects/plan-feature.vo').PlanFeature): boolean { hasFeature(feature: import('../value-objects/plan-feature.vo').PlanFeature): boolean {
return this.props.plan.hasFeature(feature); return this.accessPlan.hasFeature(feature);
} }
/** /**
* Get the maximum shipments per year allowed * Get the maximum shipments per year allowed
*/ */
get maxShipmentsPerYear(): number { get maxShipmentsPerYear(): number {
return this.props.plan.maxShipmentsPerYear; return this.accessPlan.maxShipmentsPerYear;
} }
/** /**
* Get the per-booking fee for this subscription's plan * Get the per-booking fee for this subscription's plan
*/ */
get bookingFeeEur(): number { get bookingFeeEur(): number {
return this.props.plan.bookingFeeEur; return this.accessPlan.bookingFeeEur;
} }
/** /**
* Get the status badge for this subscription's plan * Get the status badge for this subscription's plan
*/ */
get statusBadge(): string { get statusBadge(): string {
return this.props.plan.statusBadge; return this.accessPlan.statusBadge;
} }
/** /**
@ -345,6 +344,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(),
}); });

View File

@ -32,6 +32,7 @@ export enum WikiContributionStatus {
export interface WikiContributionProps { export interface WikiContributionProps {
id: string; id: string;
version: number;
locale: string; locale: string;
/** Sujet du wiki auquel la page se rattache, ex. `douanes`. */ /** Sujet du wiki auquel la page se rattache, ex. `douanes`. */
topic: string; topic: string;
@ -65,7 +66,9 @@ export class WikiContribution {
* qu'il l'explique a l'utilisateur au lieu de reessayer. * qu'il l'explique a l'utilisateur au lieu de reessayer.
*/ */
static create( static create(
props: Omit<WikiContributionProps, 'id' | 'status' | 'createdAt' | 'updatedAt'> & { id: string } props: Omit<WikiContributionProps, 'id' | 'status' | 'createdAt' | 'updatedAt' | 'version'> & {
id: string;
}
): WikiContribution { ): WikiContribution {
const draft: WikiContributionDraft = { const draft: WikiContributionDraft = {
topic: props.topic, topic: props.topic,
@ -82,6 +85,7 @@ export class WikiContribution {
...props, ...props,
// Le statut n'est pas un parametre : rien ne nait publie, pas meme une // Le statut n'est pas un parametre : rien ne nait publie, pas meme une
// page ecrite par un administrateur. // page ecrite par un administrateur.
version: 0,
status: WikiContributionStatus.PENDING, status: WikiContributionStatus.PENDING,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@ -172,6 +176,10 @@ export class WikiContribution {
}); });
} }
get version(): number {
return this.props.version;
}
get status(): WikiContributionStatus { get status(): WikiContributionStatus {
return this.props.status; return this.props.status;
} }

View File

@ -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

View File

@ -1,5 +1,12 @@
import { WikiContribution, WikiContributionStatus } from '../../entities/wiki-contribution.entity'; import { WikiContribution, WikiContributionStatus } from '../../entities/wiki-contribution.entity';
export class WikiContributionConflict extends Error {}
export interface WikiContributionWriter {
id: string;
organizationId: string;
}
export const WIKI_CONTRIBUTION_REPOSITORY = 'WikiContributionRepository'; export const WIKI_CONTRIBUTION_REPOSITORY = 'WikiContributionRepository';
export interface WikiContributionRepository { export interface WikiContributionRepository {
@ -31,7 +38,11 @@ export interface WikiContributionRepository {
*/ */
findByTitle(locale: string, topic: string, title: string): Promise<WikiContribution | null>; findByTitle(locale: string, topic: string, title: string): Promise<WikiContribution | null>;
save(contribution: WikiContribution): Promise<WikiContribution>; /** Inserts version 0; otherwise atomically updates the exact version. Contributor writes also enforce ownership and unpublished state. */
save(
contribution: WikiContribution,
contributor?: WikiContributionWriter
): Promise<WikiContribution>;
/** /**
* Empreinte du jeu **publie**, qui change des qu'une page est validee, * Empreinte du jeu **publie**, qui change des qu'une page est validee,

View File

@ -190,6 +190,7 @@ describe('WikiRetriever', () => {
describe('contributions', () => { describe('contributions', () => {
const page = WikiContribution.fromPersistence({ const page = WikiContribution.fromPersistence({
id: 'w1', id: 'w1',
version: 1,
locale: 'fr', locale: 'fr',
topic: 'vgm', topic: 'vgm',
title: 'VGM et pesée', title: 'VGM et pesée',

View File

@ -0,0 +1,97 @@
import { Logger } from '@nestjs/common';
import { EmailAdapter } from './email.adapter';
/**
* Tous les emails doivent partir de l'adresse SMTP_FROM, la seule validee chez
* le relais SMTP (Brevo). Les invitations et les demandes aux transporteurs
* partaient d'adresses codees en dur et etaient refusees.
*/
function buildAdapter(smtpFrom = 'noreply@xpeditis.com') {
const settings: Record<string, string> = {
SMTP_FROM: smtpFrom,
APP_URL: 'https://app.preprod.xpeditis.com',
};
const config = { get: jest.fn((key: string, fallback?: unknown) => settings[key] ?? fallback) };
const templates = {
renderInvitationWithToken: jest.fn(async () => '<p>invitation</p>'),
renderCsvBookingRequest: jest.fn(async () => '<p>demande</p>'),
renderUserInvitation: jest.fn(async () => '<p>compte</p>'),
renderPasswordResetEmail: jest.fn(async () => '<p>reset</p>'),
};
const adapter = new EmailAdapter(config as never, templates as never);
// Parametre type : sans lui, Jest infere un appel sans argument et
// `mock.calls[0][0]` ne compile pas.
const sendMail = jest.fn(async (_mail: { from: string; to: string }) => ({
messageId: 'm-1',
accepted: ['x'],
rejected: [],
}));
(adapter as unknown as { transporter: { sendMail: typeof sendMail } }).transporter = { sendMail };
return { adapter, sendMail };
}
const sentFrom = (sendMail: jest.Mock) => (sendMail.mock.calls[0][0] as { from: string }).from;
describe('EmailAdapter — expediteur', () => {
beforeAll(() => {
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
});
it("envoie l'invitation depuis SMTP_FROM, avec le nom de l'equipe", async () => {
const { adapter, sendMail } = buildAdapter();
await adapter.sendInvitationWithToken(
'nouveau@client.test',
'Marie',
'Dupont',
'Acme',
'Paul Martin',
'https://app/register?token=t',
new Date('2026-09-21T10:00:00Z')
);
expect(sentFrom(sendMail)).toBe('"Équipe Xpeditis" <noreply@xpeditis.com>');
});
it('envoie la demande au transporteur depuis SMTP_FROM', async () => {
const { adapter, sendMail } = buildAdapter();
await adapter.sendCsvBookingRequest('booking@ssc.test', {
bookingId: 'b-1',
bookingNumber: 'XPD-2026-AAAAAA',
origin: 'FRLEH',
destination: 'EGEDK',
volumeCBM: 2.4,
weightKG: 850,
palletCount: 2,
priceUSD: 200,
priceEUR: 180,
primaryCurrency: 'EUR',
transitDays: 11,
containerType: 'LCL',
documents: [],
confirmationToken: 'token',
});
expect(sentFrom(sendMail)).toBe('"Xpeditis Bookings" <noreply@xpeditis.com>');
expect((sendMail.mock.calls[0][0] as { to: string }).to).toBe('booking@ssc.test');
});
it("suit l'adresse configuree, pour tous les types d'email", async () => {
const { adapter, sendMail } = buildAdapter('contact@mondomaine.fr');
await adapter.sendUserInvitation('a@b.test', 'Acme', 'Paul', 'Temp-1234');
await adapter.sendPasswordResetEmail('a@b.test', 'token');
await adapter.send({ to: 'a@b.test', subject: 'Test', html: '<p>t</p>' });
const froms = sendMail.mock.calls.map(call => (call[0] as { from: string }).from);
expect(froms).toEqual([
'"Équipe Xpeditis" <contact@mondomaine.fr>',
'"Xpeditis Sécurité" <contact@mondomaine.fr>',
'"Xpeditis" <contact@mondomaine.fr>',
]);
});
});

View File

@ -1,97 +1,138 @@
import { Logger } from '@nestjs/common'; 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 { EmailAdapter } from './email.adapter';
import { EmailTemplates } from './templates/email-templates';
/** jest.mock('nodemailer', () => ({ createTransport: jest.fn() }));
* Tous les emails doivent partir de l'adresse SMTP_FROM, la seule validee chez
* le relais SMTP (Brevo). Les invitations et les demandes aux transporteurs
* partaient d'adresses codees en dur et etaient refusees.
*/
function buildAdapter(smtpFrom = 'noreply@xpeditis.com') { const configuration = (values: Record<string, unknown>) =>
const settings: Record<string, string> = { ({
SMTP_FROM: smtpFrom, get: (key: string, fallback?: unknown) => values[key] ?? fallback,
APP_URL: 'https://app.preprod.xpeditis.com', }) as ConfigService;
};
const config = { get: jest.fn((key: string, fallback?: unknown) => settings[key] ?? fallback) };
const templates = {
renderInvitationWithToken: jest.fn(async () => '<p>invitation</p>'),
renderCsvBookingRequest: jest.fn(async () => '<p>demande</p>'),
renderUserInvitation: jest.fn(async () => '<p>compte</p>'),
renderPasswordResetEmail: jest.fn(async () => '<p>reset</p>'),
};
const adapter = new EmailAdapter(config as never, templates as never);
// Parametre type : sans lui, Jest infere un appel sans argument et describe('SMTP transport security', () => {
// `mock.calls[0][0]` ne compile pas. const verify = jest.fn();
const sendMail = jest.fn(async (_mail: { from: string; to: string }) => ({ const sendMail = jest.fn();
messageId: 'm-1', const options = (environment: string, secure = false) => {
accepted: ['x'], const adapter = new EmailAdapter(
rejected: [], configuration({
})); NODE_ENV: environment,
(adapter as unknown as { transporter: { sendMail: typeof sendMail } }).transporter = { sendMail }; SMTP_PORT: secure ? 465 : 587,
SMTP_SECURE: secure,
return { adapter, sendMail }; SMTP_USER: 'test-user',
} SMTP_PASS: 'test-only-password',
}),
const sentFrom = (sendMail: jest.Mock) => (sendMail.mock.calls[0][0] as { from: string }).from; {} as EmailTemplates
describe('EmailAdapter — expediteur', () => {
beforeAll(() => {
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
});
it("envoie l'invitation depuis SMTP_FROM, avec le nom de l'equipe", async () => {
const { adapter, sendMail } = buildAdapter();
await adapter.sendInvitationWithToken(
'nouveau@client.test',
'Marie',
'Dupont',
'Acme',
'Paul Martin',
'https://app/register?token=t',
new Date('2026-09-21T10:00:00Z')
); );
adapter['buildTransporter']('127.0.0.1', 'smtp.example.org');
expect(sentFrom(sendMail)).toBe('"Équipe Xpeditis" <noreply@xpeditis.com>'); 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('envoie la demande au transporteur depuis SMTP_FROM', async () => { it('requires production TLS and validates the original hostname after IP resolution', () => {
const { adapter, sendMail } = buildAdapter(); const { config } = options('production');
expect(config.requireTLS).toBe(true);
await adapter.sendCsvBookingRequest('booking@ssc.test', { expect(config.tls).toMatchObject({ rejectUnauthorized: true, servername: 'smtp.example.org' });
bookingId: 'b-1', expect(config.host).toBe('127.0.0.1');
bookingNumber: 'XPD-2026-AAAAAA', expect(config.secure).toBe(false);
origin: 'FRLEH',
destination: 'EGEDK',
volumeCBM: 2.4,
weightKG: 850,
palletCount: 2,
priceUSD: 200,
priceEUR: 180,
primaryCurrency: 'EUR',
transitDays: 11,
containerType: 'LCL',
documents: [],
confirmationToken: 'token',
}); });
it('preserves implicit TLS and local development plaintext support', () => {
expect(sentFrom(sendMail)).toBe('"Xpeditis Bookings" <noreply@xpeditis.com>'); expect(options('production', true).config.secure).toBe(true);
expect((sendMail.mock.calls[0][0] as { to: string }).to).toBe('booking@ssc.test'); expect(options('development').config.requireTLS).toBe(false);
expect(options('development').config.tls?.rejectUnauthorized).toBe(true);
}); });
it('propagates secure delivery failures', async () => {
it("suit l'adresse configuree, pour tous les types d'email", async () => { const { adapter } = options('production');
const { adapter, sendMail } = buildAdapter('contact@mondomaine.fr'); sendMail.mockRejectedValue(new Error('certificate verification failed: secret-fixture'));
await expect(
await adapter.sendUserInvitation('a@b.test', 'Acme', 'Paul', 'Temp-1234'); adapter.send({ to: 'test@example.org', subject: 'Test', text: 'Test' })
await adapter.sendPasswordResetEmail('a@b.test', 'token'); ).rejects.toThrow('Email delivery failed');
await adapter.send({ to: 'a@b.test', subject: 'Test', html: '<p>t</p>' }); });
});
const froms = sendMail.mock.calls.map(call => (call[0] as { from: string }).from);
expect(froms).toEqual([ describe('SMTP STARTTLS downgrade regression', () => {
'"Équipe Xpeditis" <contact@mondomaine.fr>', let server: Server;
'"Xpeditis Sécurité" <contact@mondomaine.fr>', const sockets = new Set<Socket>();
'"Xpeditis" <contact@mondomaine.fr>', 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();
}
}); });
}); });

View File

@ -153,9 +153,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,
@ -212,8 +213,8 @@ export class EmailAdapter implements EmailPort, OnModuleInit {
`✅ Email submitted — to: ${options.to} | from: ${from} | subject: "${options.subject}" | messageId: ${info.messageId} | accepted: ${JSON.stringify(info.accepted)} | rejected: ${JSON.stringify(info.rejected)}` `✅ Email submitted — to: ${options.to} | from: ${from} | subject: "${options.subject}" | messageId: ${info.messageId} | accepted: ${JSON.stringify(info.accepted)} | rejected: ${JSON.stringify(info.rejected)}`
); );
} catch (error) { } catch (error) {
this.logger.error(`Failed to send email to ${options.to}`, error); this.logger.error('Email delivery failed');
throw error; throw new Error('Email delivery failed');
} }
} }
@ -317,11 +318,9 @@ export class EmailAdapter implements EmailPort, OnModuleInit {
this.logger.log(`Invitation email sent to ${email} for ${organizationName}`); this.logger.log(`Invitation email sent to ${email} for ${organizationName}`);
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error); this.logger.error('Invitation email delivery failed');
this.logger.error(
`[sendInvitationWithToken] ${errorMessage} | code: ${(error as any)?.code} | response: ${(error as any)?.response}` throw new Error('Invitation email delivery failed');
);
throw error;
} }
} }

View File

@ -68,14 +68,14 @@ describe('decideAdminBootstrap', () => {
}); });
}); });
it('promeut et reactive un compte existant, sans hash', () => { it('refuse de promouvoir un compte non administrateur existant', () => {
const decision = decideAdminBootstrap(account({ role: 'USER', isActive: false }), { const decision = decideAdminBootstrap(account({ role: 'USER', isActive: false }), {
resetPassword: false, resetPassword: false,
}); });
expect(decision).toEqual({ expect(decision).toEqual({
create: false, create: false,
promote: true, promote: false,
activate: true, activate: false,
applyPassword: false, applyPassword: false,
}); });
}); });

View File

@ -15,10 +15,11 @@
* *
* CE QUE FAIT CE SERVICE * CE QUE FAIT CE SERVICE
* ---------------------- * ----------------------
* Si BOOTSTRAP_ADMIN_EMAIL est renseigné, le compte est garanti ADMIN et actif, * Si BOOTSTRAP_ADMIN_EMAIL est renseigné, un compte absent est créé ADMIN.
* et créé s'il n'existe pas. BOOTSTRAP_ADMIN_PASSWORD_HASH est appliqué : * Un compte existant non administrateur n'est jamais promu automatiquement.
* Seul un administrateur existant peut être réactivé. BOOTSTRAP_ADMIN_PASSWORD_HASH est appliqué :
* - à un compte qui ne s'est encore jamais connecté (compte d'amorçage) ; * - à un compte qui ne s'est encore jamais connecté (compte d'amorçage) ;
* - ou à tout compte si BOOTSTRAP_ADMIN_RESET_PASSWORD=true (à retirer * - ou à un administrateur si BOOTSTRAP_ADMIN_RESET_PASSWORD=true (à retirer
* ensuite : tant qu'il reste, un mot de passe changé depuis l'interface * ensuite : tant qu'il reste, un mot de passe changé depuis l'interface
* serait remplacé au lancement suivant). * serait remplacé au lancement suivant).
* En dehors de ces deux cas, le mot de passe choisi par l'administrateur n'est * En dehors de ces deux cas, le mot de passe choisi par l'administrateur n'est
@ -34,6 +35,7 @@ import { ConfigService } from '@nestjs/config';
import { DataSource, EntityManager } from 'typeorm'; import { DataSource, EntityManager } from 'typeorm';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import * as argon2 from 'argon2'; import * as argon2 from 'argon2';
import { isProductionDeployment } from './deployment-environment';
/** Paramètres Argon2id du projet (cf. auth.service.ts). */ /** Paramètres Argon2id du projet (cf. auth.service.ts). */
const ARGON2_OPTIONS = { const ARGON2_OPTIONS = {
@ -89,6 +91,11 @@ export function decideAdminBootstrap(
return { create: true, promote: false, activate: false, applyPassword: !!config.passwordHash }; return { create: true, promote: false, activate: false, applyPassword: !!config.passwordHash };
} }
// An address is not proof that a self-registered account belongs to the operator.
if (account.role !== 'ADMIN') {
return { create: false, promote: false, activate: false, applyPassword: false };
}
const hashAlreadyApplied = account.passwordHash === config.passwordHash; const hashAlreadyApplied = account.passwordHash === config.passwordHash;
const applyPassword = const applyPassword =
!!config.passwordHash && !!config.passwordHash &&
@ -97,7 +104,7 @@ export function decideAdminBootstrap(
return { return {
create: false, create: false,
promote: account.role !== 'ADMIN', promote: false,
activate: !account.isActive, activate: !account.isActive,
applyPassword, applyPassword,
}; };
@ -173,6 +180,13 @@ export class AdminBootstrapService implements OnApplicationBootstrap {
); );
if (config) { if (config) {
if (
config.passwordHash &&
isProductionDeployment() &&
(await argon2.verify(config.passwordHash, 'Password123!'))
) {
throw new Error('The public demonstration password cannot be used for an administrator.');
}
await this.dataSource.transaction(manager => this.applyConfig(manager, config)); await this.dataSource.transaction(manager => this.applyConfig(manager, config));
} }
@ -192,7 +206,7 @@ export class AdminBootstrapService implements OnApplicationBootstrap {
last_login_at: Date | null; last_login_at: Date | null;
password_hash: string; password_hash: string;
}> = await manager.query( }> = await manager.query(
`SELECT "id", "role", "is_active", "last_login_at", "password_hash" FROM "users" WHERE "email" = $1`, `SELECT "id", "role", "is_active", "last_login_at", "password_hash" FROM "users" WHERE "email" = $1 FOR UPDATE`,
[config.email] [config.email]
); );
@ -206,6 +220,12 @@ export class AdminBootstrapService implements OnApplicationBootstrap {
} }
: null; : null;
if (account && account.role !== 'ADMIN') {
this.logger.error(
'[amorçage admin] Adresse déjà occupée par un compte non administrateur : aucune promotion automatique.'
);
return;
}
const decision = decideAdminBootstrap(account, config); const decision = decideAdminBootstrap(account, config);
if (decision.create) { if (decision.create) {
@ -225,11 +245,10 @@ export class AdminBootstrapService implements OnApplicationBootstrap {
await manager.query( await manager.query(
`UPDATE "users" `UPDATE "users"
SET "role" = 'ADMIN', SET "is_active" = true,
"is_active" = true,
"password_hash" = CASE WHEN $2::boolean THEN $3 ELSE "password_hash" END, "password_hash" = CASE WHEN $2::boolean THEN $3 ELSE "password_hash" END,
"updated_at" = NOW() "updated_at" = NOW()
WHERE "id" = $1`, WHERE "id" = $1 AND "role" = 'ADMIN'`,
[account!.id, decision.applyPassword, config.passwordHash ?? ''] [account!.id, decision.applyPassword, config.passwordHash ?? '']
); );

View File

@ -0,0 +1,45 @@
import { decideAdminBootstrap } from './admin-bootstrap.service';
import { isProductionDeployment } from './deployment-environment';
it.each([false, true])('does not promote an email claimant, reset=%s', resetPassword => {
const result = decideAdminBootstrap(
{
id: 'claimant',
role: 'USER',
isActive: true,
passwordHash: 'attacker-hash',
lastLoginAt: new Date(),
},
{ passwordHash: 'operator-hash', resetPassword }
);
expect(result).toEqual({ create: false, promote: false, activate: false, applyPassword: false });
});
it('protects a production runtime deployed as preprod from demo credentials', () => {
expect(isProductionDeployment({ NODE_ENV: 'production', APP_ENV: 'preprod' })).toBe(true);
});
import * as argon2 from 'argon2';
import { GuardPendingLegacyAdminBootstrap1755999999999 } from './migrations/1755999999999-GuardPendingLegacyAdminBootstrap';
it('rejects a public bootstrap password at any configured address before the legacy insertion', async () => {
const keys = ['NODE_ENV', 'BOOTSTRAP_ADMIN_EMAIL', 'BOOTSTRAP_ADMIN_PASSWORD_HASH'];
const old = keys.map(key => process.env[key]);
process.env.NODE_ENV = 'production';
process.env.BOOTSTRAP_ADMIN_EMAIL = 'operator@example.test';
process.env.BOOTSTRAP_ADMIN_PASSWORD_HASH = await argon2.hash('Password123!');
const query = jest.fn().mockResolvedValue([]);
try {
await expect(
new GuardPendingLegacyAdminBootstrap1755999999999().up({
query,
isTransactionActive: true,
} as never)
).rejects.toThrow('public demonstration password');
expect(query).toHaveBeenCalledTimes(1);
} finally {
keys.forEach((key, i) => {
if (old[i] === undefined) delete process.env[key];
else process.env[key] = old[i];
});
}
});

View File

@ -4,9 +4,11 @@
* Used for migrations and CLI commands * Used for migrations and CLI commands
*/ */
import { SafeDatabaseLogger } from './safe-database-logger';
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();
@ -21,7 +23,13 @@ export const AppDataSource = new DataSource({
entities: [join(__dirname, 'entities', '*.orm-entity.{ts,js}')], entities: [join(__dirname, 'entities', '*.orm-entity.{ts,js}')],
migrations: [join(__dirname, 'migrations', '*.{ts,js}')], migrations: [join(__dirname, 'migrations', '*.{ts,js}')],
subscribers: [], subscribers: [],
migrationsTransactionMode: 'all',
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, logger: new SafeDatabaseLogger(process.env.NODE_ENV === 'development'),
ssl: databaseTlsOptions(
process.env.DATABASE_SSL,
process.env.DATABASE_SSL_CA,
process.env.DATABASE_HOST
),
}); });

View File

@ -0,0 +1,96 @@
import { readFileSync } from 'fs';
import { resolve, join } from 'path';
import { runInNewContext } from 'vm';
import { SafeDatabaseLogger } from './safe-database-logger';
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() };
if (
name === `${applicationRoot}/dist/infrastructure/persistence/typeorm/safe-database-logger`
)
return { SafeDatabaseLogger };
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.logger).toBeInstanceOf(SafeDatabaseLogger);
expect(config.migrationsTransactionMode).toBe('all');
expect(config.migrations).toEqual([
`${applicationRoot}/dist/infrastructure/persistence/typeorm/migrations/*.js`,
]);
}
expect(processExit).not.toHaveBeenCalledWith(1);
}
);
});

View File

@ -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',
});
});
});

View File

@ -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 } : {}),
};
}

View File

@ -1,19 +1,20 @@
import { isProductionDeployment } from './deployment-environment'; import { isProductionDeployment } from './deployment-environment';
describe('isProductionDeployment', () => { describe('deployment credential protection', () => {
it('traite la preprod comme non productive meme avec NODE_ENV=production', () => { it.each(['preprod', 'production', 'prod', 'staging', 'preview', '', 'unknown'])(
expect(isProductionDeployment({ NODE_ENV: 'production', APP_ENV: 'preprod' })).toBe(false); 'protects %s',
}); APP_ENV => {
expect(isProductionDeployment({ APP_ENV })).toBe(true);
it('reconnait APP_ENV=production, quelle que soit la casse', () => { }
expect(isProductionDeployment({ NODE_ENV: 'production', APP_ENV: ' Production ' })).toBe(true); );
expect(isProductionDeployment({ APP_ENV: 'prod' })).toBe(true); it.each(['dev', 'development', 'local', 'test'])(
}); 'allows explicit isolated %s only outside production runtime',
APP_ENV => {
it('retombe sur NODE_ENV quand APP_ENV est absent ou vide', () => { expect(isProductionDeployment({ NODE_ENV: 'test', APP_ENV })).toBe(false);
expect(isProductionDeployment({ NODE_ENV: 'production' })).toBe(true); expect(isProductionDeployment({ NODE_ENV: 'production', APP_ENV })).toBe(true);
expect(isProductionDeployment({ NODE_ENV: 'production', APP_ENV: '' })).toBe(true); }
expect(isProductionDeployment({ NODE_ENV: 'development' })).toBe(false); );
expect(isProductionDeployment({})).toBe(false); it('fails closed for unspecified environment', () => {
expect(isProductionDeployment({})).toBe(true);
}); });
}); });

View File

@ -1,24 +1,21 @@
/** /**
* Environnement de déploiement, tel que le voient les migrations. * Security classification used by legacy seed migrations.
* Public deployments, unknown environments and production runtimes are protected.
* Demo credentials are allowed only in explicitly local development/test runtimes.
* *
* NODE_ENV ne suffit pas à le déterminer : c'est un réglage d'exécution Node * NODE_ENV=production always wins over APP_ENV: preproduction runs a production
* (optimisations, cookies Secure…) et la preprod le positionne légitimement à * Node runtime and is exposed through the same application login boundary.
* "production". S'y fier seul a fait neutraliser les comptes de démonstration * An empty or misspelled deployment name must not enable known passwords.
* de la preprod, alors qu'ils devaient y rester utilisables.
* *
* APP_ENV dit OÙ l'on déploie (development, preprod, production). Il prime dès * This compatibility helper is imported by already-applied migration classes;
* qu'il est renseigné. En son absence, on retombe sur NODE_ENV : une production * their source remains unchanged. A separate forward migration also rotates
* qui n'aurait pas encore défini APP_ENV reste ainsi protégée. * exposed credentials on databases where the older neutralization already ran.
* * A protected environment can still bootstrap an administrator using an
* Fichier volontairement hors du dossier migrations/ : TypeORM chargerait sinon * operator-provided, non-demonstration password hash or email recovery.
* tout module de ce dossier comme une migration.
*/ */
export function isProductionDeployment(env: NodeJS.ProcessEnv = process.env): boolean { export function isProductionDeployment(env: NodeJS.ProcessEnv = process.env): boolean {
const nodeEnv = (env.NODE_ENV ?? '').trim().toLowerCase();
const appEnv = (env.APP_ENV ?? '').trim().toLowerCase(); const appEnv = (env.APP_ENV ?? '').trim().toLowerCase();
if (nodeEnv === 'production') return true;
if (appEnv) { return !['development', 'dev', 'local', 'test'].includes(appEnv || nodeEnv);
return appEnv === 'production' || appEnv === 'prod';
}
return env.NODE_ENV === 'production';
} }

View File

@ -0,0 +1,38 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import * as argon2 from 'argon2';
import { isProductionDeployment } from '../deployment-environment';
/** Deliberately ordered BEFORE the immutable legacy bootstrap migration.
* Both supported runners use the default all-migrations transaction. Refuse an
* unsafe upgrade instead of letting the old migration promote an email claimant.
*/
export class GuardPendingLegacyAdminBootstrap1755999999999 implements MigrationInterface {
async up(queryRunner: QueryRunner): Promise<void> {
const applied: unknown[] = await queryRunner.query('SELECT 1 FROM migrations WHERE name=$1', [
'BootstrapAdminFromEnv1756000000001',
]);
if (applied.length) return;
const email = (process.env.BOOTSTRAP_ADMIN_EMAIL ?? '').trim().toLowerCase();
if (!email) return;
const hash = (process.env.BOOTSTRAP_ADMIN_PASSWORD_HASH ?? '').trim();
if (hash && isProductionDeployment() && (await argon2.verify(hash, 'Password123!'))) {
throw new Error('The public demonstration password cannot be used for bootstrap.');
}
if (!queryRunner.isTransactionActive) {
throw new Error('Bootstrap safety requires migration transaction mode all.');
}
await queryRunner.query('LOCK TABLE users IN SHARE ROW EXCLUSIVE MODE');
const rows: Array<{ role: string }> = await queryRunner.query(
'SELECT role FROM users WHERE email=$1',
[email]
);
if (rows.some(row => row.role !== 'ADMIN')) {
throw new Error(
'Bootstrap email belongs to an existing non-administrator. Choose an unused bootstrap address before migrating.'
);
}
}
async down(): Promise<void> {
/* No persistent data changed. */
}
}

View File

@ -0,0 +1,12 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class VersionWikiContributions1790000000003 implements MigrationInterface {
async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE wiki_contributions ADD COLUMN version integer NOT NULL DEFAULT 1 CHECK (version > 0)'
);
}
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE wiki_contributions DROP COLUMN version');
}
}

View File

@ -0,0 +1,46 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import * as argon2 from 'argon2';
import { randomBytes } from 'crypto';
import { isProductionDeployment } from '../deployment-environment';
/** Forward-only cleanup for databases whose older neutralization already ran. */
export class RotatePublicDemoCredentials1790000000004 implements MigrationInterface {
async up(queryRunner: QueryRunner): Promise<void> {
if (!isProductionDeployment()) return;
const rows: Array<{ id: string; password_hash: string }> = await queryRunner.query(
'SELECT id, password_hash FROM users WHERE email = ANY($1) FOR UPDATE',
[
[
'admin@xpeditis.com',
'manager@xpeditis.com',
'user@xpeditis.com',
(process.env.BOOTSTRAP_ADMIN_EMAIL ?? '').trim().toLowerCase(),
],
]
);
for (const row of rows) {
let exposed = false;
try {
exposed = await argon2.verify(row.password_hash, 'Password123!');
} catch {
/* Invalid hashes cannot authenticate. */
}
if (!exposed) continue;
const hash = await argon2.hash(randomBytes(48).toString('hex'), {
type: argon2.argon2id,
memoryCost: 65536,
timeCost: 3,
parallelism: 4,
});
// Keep the last administrator active and preserve concurrent customized credentials.
// The changed hash invalidates existing access and refresh credentialVersions.
await queryRunner.query(
'UPDATE users SET password_hash=$2, updated_at=NOW() WHERE id=$1 AND password_hash=$3',
[row.id, hash, row.password_hash]
);
}
}
async down(): Promise<void> {
/* Never restore publicly known credentials. */
}
}

View File

@ -46,14 +46,14 @@ export class TypeOrmCsvBookingRepository implements CsvBookingRepositoryPort {
} }
async findByToken(token: string): Promise<CsvBooking | null> { async findByToken(token: string): Promise<CsvBooking | null> {
this.logger.log(`Finding CSV booking by token: ${token}`); this.logger.log('Finding CSV booking by token');
const ormEntity = await this.repository.findOne({ const ormEntity = await this.repository.findOne({
where: { confirmationToken: token }, where: { confirmationToken: token },
}); });
if (!ormEntity) { if (!ormEntity) {
this.logger.log(`CSV booking not found for token: ${token}`); this.logger.log('CSV booking not found for token');
return null; return null;
} }

View File

@ -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(
{ id, user_id: userId },
{
read: true, read: true,
read_at: new Date(), read_at: new Date(),
}); }
);
} }
async markAllAsReadForUser(userId: string): Promise<void> { async markAllAsReadForUser(userId: string): Promise<void> {

View File

@ -4,7 +4,11 @@ import {
WikiContribution, WikiContribution,
WikiContributionStatus, WikiContributionStatus,
} from '@domain/entities/wiki-contribution.entity'; } from '@domain/entities/wiki-contribution.entity';
import { WikiContributionRepository } from '@domain/ports/out/wiki-contribution.repository'; import {
WikiContributionConflict,
WikiContributionRepository,
WikiContributionWriter,
} from '@domain/ports/out/wiki-contribution.repository';
/** /**
* Pages du wiki ecrites a l'execution. * Pages du wiki ecrites a l'execution.
@ -60,29 +64,27 @@ export class TypeOrmWikiContributionRepository implements WikiContributionReposi
return rows.length ? toDomain(rows[0]) : null; return rows.length ? toDomain(rows[0]) : null;
} }
/** async save(
* Ecrit la page, ou remplace celle qui porte deja ce titre. contribution: WikiContribution,
* contributor?: WikiContributionWriter
* Le conflit est resolu en base et non par un `find` prealable : deux ): Promise<WikiContribution> {
* questions simultanees sur le meme sujet manquant produiraient sinon deux
* pages jumelles, l'index unique se contentant de faire echouer la seconde.
*/
async save(contribution: WikiContribution): Promise<WikiContribution> {
const page = contribution.toObject(); const page = contribution.toObject();
const rows: Row[] = await this.db.query( if (
contributor &&
(page.authorUserId !== contributor.id ||
page.authorOrganizationId !== contributor.organizationId ||
page.status !== WikiContributionStatus.PENDING)
) {
throw new WikiContributionConflict('Contribution not writable');
}
let rows: Row[];
if (page.version === 0) {
rows = await this.db.query(
`INSERT INTO wiki_contributions `INSERT INTO wiki_contributions
(id, locale, topic, title, section, body, status, author_user_id, author_organization_id, (id, locale, topic, title, section, body, status, author_user_id, author_organization_id,
reviewed_by_user_id, reviewed_at, review_note, created_at, updated_at) reviewed_by_user_id, reviewed_at, review_note, created_at, updated_at, version)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,1)
ON CONFLICT (locale, topic, lower(title)) DO UPDATE ON CONFLICT DO NOTHING RETURNING *`,
SET section = EXCLUDED.section,
body = EXCLUDED.body,
status = EXCLUDED.status,
reviewed_by_user_id = EXCLUDED.reviewed_by_user_id,
reviewed_at = EXCLUDED.reviewed_at,
review_note = EXCLUDED.review_note,
updated_at = EXCLUDED.updated_at
RETURNING *`,
[ [
page.id, page.id,
page.locale, page.locale,
@ -100,6 +102,34 @@ export class TypeOrmWikiContributionRepository implements WikiContributionReposi
page.updatedAt, page.updatedAt,
] ]
); );
} else {
const [updatedRows]: [Row[], number] = await this.db.query(
`UPDATE wiki_contributions SET section=$3, body=$4, status=$5,
reviewed_by_user_id=$6, reviewed_at=$7, review_note=$8, updated_at=$9,
version=version+1
WHERE id=$1 AND version=$2
AND ($10::boolean = false OR (author_user_id=$11 AND author_organization_id=$12
AND status IN ('pending','rejected')))
RETURNING *`,
[
page.id,
page.version,
page.section,
page.body,
page.status,
page.reviewedByUserId ?? null,
page.reviewedAt ?? null,
page.reviewNote ?? null,
page.updatedAt,
!!contributor,
contributor?.id ?? null,
contributor?.organizationId ?? null,
]
);
rows = updatedRows;
}
if (!rows.length)
throw new WikiContributionConflict('Contribution changed or title already exists');
return toDomain(rows[0]); return toDomain(rows[0]);
} }
@ -116,6 +146,7 @@ export class TypeOrmWikiContributionRepository implements WikiContributionReposi
} }
interface Row { interface Row {
version: number;
id: string; id: string;
locale: string; locale: string;
topic: string; topic: string;
@ -139,6 +170,7 @@ interface Row {
function toDomain(row: Row): WikiContribution { function toDomain(row: Row): WikiContribution {
return WikiContribution.fromPersistence({ return WikiContribution.fromPersistence({
id: row.id, id: row.id,
version: row.version,
locale: row.locale, locale: row.locale,
topic: row.topic, topic: row.topic,
title: row.title, title: row.title,

View File

@ -0,0 +1,30 @@
import { Logger } from '@nestjs/common';
import { SafeDatabaseLogger } from './safe-database-logger';
describe('Database credential logging boundary', () => {
afterEach(() => jest.restoreAllMocks());
it('preserves events without SQL values, bind parameters or driver errors', () => {
const logs = jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
const errors = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
const warns = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
const logger = new SafeDatabaseLogger('all');
const secret = 'synthetic-carrier-token';
logger.logQuery(`SELECT '${secret}'`, [secret]);
logger.logQueryError(secret, `SELECT '${secret}'`, [secret]);
logger.logQuerySlow(2000, `SELECT '${secret}'`, [secret]);
logger.logMigration(secret);
logger.logSchemaBuild(secret);
logger.log('warn', secret);
expect(logs).toHaveBeenCalled();
expect(errors).toHaveBeenCalled();
expect(warns).toHaveBeenCalled();
expect(JSON.stringify([logs.mock.calls, errors.mock.calls, warns.mock.calls])).not.toContain(
secret
);
});
it('respects disabled query logging', () => {
const logs = jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
new SafeDatabaseLogger(false).logQuery('SELECT 1');
expect(logs).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,20 @@
import { Logger } from '@nestjs/common';
import { AbstractLogger, LogLevel, LogMessage } from 'typeorm';
/** Keep configured database events without credentials in SQL, parameters or driver errors. */
export class SafeDatabaseLogger extends AbstractLogger {
private readonly logger = new Logger('Database');
protected writeLog(level: LogLevel, messages: LogMessage | LogMessage[]): void {
const first = Array.isArray(messages) ? messages[0] : messages;
const category = ['query', 'query-error', 'query-slow', 'schema-build', 'migration'].includes(
first?.type ?? ''
)
? first.type
: level;
const message = `Database event: ${category}`;
if (category === 'query-error' || level === 'error') this.logger.error(message);
else if (level === 'warn') this.logger.warn(message);
else this.logger.log(message);
}
}

View File

@ -58,14 +58,14 @@ describe('NeutralizeSeedAccountsInProduction1756000000000', () => {
jest.restoreAllMocks(); jest.restoreAllMocks();
}); });
it('ne touche a rien en preprod, meme avec NODE_ENV=production', async () => { it('controle aussi la preprod avec NODE_ENV=production', async () => {
process.env.NODE_ENV = 'production'; process.env.NODE_ENV = 'production';
process.env.APP_ENV = 'preprod'; process.env.APP_ENV = 'preprod';
const { runner, query } = fakeQueryRunner([]); const { runner, query } = fakeQueryRunner([]);
await migration.up(runner); await migration.up(runner);
expect(query).not.toHaveBeenCalled(); expect(query).toHaveBeenCalled();
}); });
it('en production, neutralise un compte au mot de passe public et conserve un compte personnalise', async () => { it('en production, neutralise un compte au mot de passe public et conserve un compte personnalise', async () => {

View File

@ -0,0 +1,182 @@
import { randomUUID } from 'crypto';
import { DataSource } from 'typeorm';
import * as argon2 from 'argon2';
import { WikiContribution } from '@domain/entities/wiki-contribution.entity';
import { WikiContributionConflict } from '@domain/ports/out/wiki-contribution.repository';
import { TypeOrmWikiContributionRepository } from './repositories/typeorm-wiki-contribution.repository';
import { CreateWikiContributions1789000000000 } from './migrations/1789000000000-CreateWikiContributions';
import { VersionWikiContributions1790000000003 } from './migrations/1790000000003-VersionWikiContributions';
import { RotatePublicDemoCredentials1790000000004 } from './migrations/1790000000004-RotatePublicDemoCredentials';
import { GuardPendingLegacyAdminBootstrap1755999999999 } from './migrations/1755999999999-GuardPendingLegacyAdminBootstrap';
import { AdminBootstrapService } from './admin-bootstrap.service';
// Explicit isolated Unix socket only; never fall back to the project's database.
const socket = process.env.XPEDITIS_SECURITY_TEST_SOCKET;
const isolated = !!socket && /^\/tmp\/xpeditis-audit-pg\.[A-Za-z0-9]+$/.test(socket);
(isolated ? describe : describe.skip)('PostgreSQL security boundaries (isolated cluster)', () => {
let db: DataSource;
let repo: TypeOrmWikiContributionRepository;
const author = { id: randomUUID(), organizationId: randomUUID() };
const intruder = { id: randomUUID(), organizationId: randomUUID() };
const admin = randomUUID();
const body =
'Le manifeste de cargaison décrit les marchandises transportées par voie maritime internationale. Le transitaire le transmet aux autorités douanières avant le chargement au port de départ. Les informations doivent être vérifiées pour éviter une immobilisation des marchandises au terminal.';
const make = (title = 'Le manifeste maritime', writer = author) =>
WikiContribution.create({
id: randomUUID(),
locale: 'fr',
topic: 'douanes',
title,
section: 'Transmission du manifeste',
body,
authorUserId: writer.id,
authorOrganizationId: writer.organizationId,
});
beforeAll(async () => {
db = new DataSource({
type: 'postgres',
host: socket,
port: 55437,
database: 'postgres',
extra: { max: 1 },
synchronize: false,
});
await db.initialize();
const schema = `audit_${randomUUID().replace(/-/g, '')}`;
await db.query(`CREATE SCHEMA "${schema}"`);
await db.query(`SET search_path TO "${schema}",public`);
await db.query('CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA public');
await db.query('ALTER EXTENSION "uuid-ossp" SET SCHEMA public');
await db.query(
'CREATE TABLE users (id uuid PRIMARY KEY, email text UNIQUE, role text, is_active boolean, password_hash text, last_login_at timestamptz, updated_at timestamptz)'
);
for (const id of [author.id, intruder.id, admin])
await db.query('INSERT INTO users(id) VALUES($1)', [id]);
const migrationRunner = db.createQueryRunner();
try {
await new CreateWikiContributions1789000000000().up(migrationRunner);
await new VersionWikiContributions1790000000003().up(migrationRunner);
} finally {
await migrationRunner.release();
}
repo = new TypeOrmWikiContributionRepository(db);
});
afterAll(async () => {
await db?.destroy();
});
beforeEach(async () => {
await db.query('DELETE FROM wiki_contributions');
});
it('does not overwrite an existing foreign title, including a concurrent case-insensitive collision', async () => {
const outcomes = await Promise.allSettled([
repo.save(make(), author),
repo.save(make('LE MANIFESTE MARITIME', intruder), intruder),
]);
expect(outcomes.filter(o => o.status === 'fulfilled')).toHaveLength(1);
expect(outcomes.filter(o => o.status === 'rejected')).toHaveLength(1);
const pages = await repo.findForReview();
expect(pages).toHaveLength(1);
expect(pages[0].version).toBe(1);
});
it('allows own unpublished revision but rejects foreign and published writes at SQL boundary', async () => {
const original = await repo.save(make(), author);
await expect(
repo.save(original.revise('Autre section', body), intruder)
).rejects.toBeInstanceOf(WikiContributionConflict);
const revised = await repo.save(original.revise('Autre section', body), author);
expect(revised.version).toBe(2);
const published = await repo.save(revised.publish(admin));
await expect(repo.save(published.revise('Autre section', body), author)).rejects.toBeInstanceOf(
WikiContributionConflict
);
expect((await repo.findPublished('fr'))[0].version).toBe(3);
});
it('rejects a stale approval after a contributor revision without publishing unseen text', async () => {
const seen = await repo.save(make(), author);
await repo.save(seen.revise('Version révisée', body + ' Vérifiez les documents.'), author);
await expect(repo.save(seen.publish(admin))).rejects.toBeInstanceOf(WikiContributionConflict);
expect(await repo.findPublished('fr')).toHaveLength(0);
});
it('allows exactly one competing decision and rejects stale contributor writes', async () => {
const seen = await repo.save(make(), author);
const outcomes = await Promise.allSettled([
repo.save(seen.publish(admin)),
repo.save(seen.reject(admin, 'Refus')),
]);
expect(outcomes.filter(o => o.status === 'fulfilled')).toHaveLength(1);
await expect(repo.save(seen.revise('Version révisée', body), author)).rejects.toBeInstanceOf(
WikiContributionConflict
);
});
it('rotates known demo credentials without disabling the last admin or touching customized credentials', async () => {
const env = { NODE_ENV: process.env.NODE_ENV, APP_ENV: process.env.APP_ENV };
process.env.NODE_ENV = 'production';
process.env.APP_ENV = 'preprod';
const demo = await argon2.hash('Password123!');
const custom = await argon2.hash('test-only-personal-password-482');
await db.query(
'UPDATE users SET email=$2, role=$3, is_active=true, password_hash=$4 WHERE id=$1',
[admin, 'admin@xpeditis.com', 'ADMIN', demo]
);
await db.query(
'UPDATE users SET email=$2, role=$3, is_active=true, password_hash=$4 WHERE id=$1',
[author.id, 'user@xpeditis.com', 'USER', custom]
);
const runner = db.createQueryRunner();
await runner.startTransaction();
try {
await new RotatePublicDemoCredentials1790000000004().up(runner);
await runner.commitTransaction();
} finally {
await runner.release();
for (const [key, value] of Object.entries(env)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
const [row] = await db.query('SELECT * FROM users WHERE id=$1', [admin]);
expect(row.role).toBe('ADMIN');
expect(row.is_active).toBe(true);
expect(await argon2.verify(row.password_hash, 'Password123!')).toBe(false);
expect(
(await db.query('SELECT password_hash FROM users WHERE id=$1', [author.id]))[0].password_hash
).toBe(custom);
});
it('does not promote a claimed bootstrap email during runtime startup', async () => {
await db.query(
'UPDATE users SET email=$2,role=$3,is_active=true,password_hash=$4,last_login_at=NOW() WHERE id=$1',
[intruder.id, 'claim@example.test', 'USER', 'attacker-hash']
);
const config = {
get: (key: string) => (key === 'BOOTSTRAP_ADMIN_EMAIL' ? 'claim@example.test' : undefined),
};
await new AdminBootstrapService(config as never, db).run();
const [row] = await db.query('SELECT role,password_hash FROM users WHERE id=$1', [intruder.id]);
expect(row).toEqual({ role: 'USER', password_hash: 'attacker-hash' });
});
it('blocks the pending immutable legacy bootstrap before it could promote an email claimant', async () => {
await db.query('CREATE TABLE migrations (name text)');
const old = process.env.BOOTSTRAP_ADMIN_EMAIL;
process.env.BOOTSTRAP_ADMIN_EMAIL = 'claim@example.test';
const runner = db.createQueryRunner();
await runner.startTransaction();
try {
await expect(new GuardPendingLegacyAdminBootstrap1755999999999().up(runner)).rejects.toThrow(
'existing non-administrator'
);
} finally {
await runner.rollbackTransaction();
await runner.release();
if (old === undefined) delete process.env.BOOTSTRAP_ADMIN_EMAIL;
else process.env.BOOTSTRAP_ADMIN_EMAIL = old;
}
});
});

View File

@ -52,8 +52,15 @@ export default function WikiReviewPage() {
}; };
const publish = useMutation({ const publish = useMutation({
mutationFn: ({ id, body }: { id: string; body?: string }) => mutationFn: ({
publishWikiContribution(id, body ? { body } : undefined), id,
expectedVersion,
body,
}: {
id: string;
expectedVersion: number;
body?: string;
}) => publishWikiContribution(id, { expectedVersion, body }),
onSuccess: page => { onSuccess: page => {
toast.success(t('publishDone', { title: page.title })); toast.success(t('publishDone', { title: page.title }));
refresh(); refresh();
@ -62,7 +69,15 @@ export default function WikiReviewPage() {
}); });
const reject = useMutation({ const reject = useMutation({
mutationFn: ({ id, note }: { id: string; note?: string }) => rejectWikiContribution(id, note), mutationFn: ({
id,
expectedVersion,
note,
}: {
id: string;
expectedVersion: number;
note?: string;
}) => rejectWikiContribution(id, expectedVersion, note),
onSuccess: page => { onSuccess: page => {
toast.success(t('rejectDone', { title: page.title })); toast.success(t('rejectDone', { title: page.title }));
refresh(); refresh();
@ -114,12 +129,12 @@ export default function WikiReviewPage() {
) : ( ) : (
pages.map(page => ( pages.map(page => (
<ReviewCard <ReviewCard
key={page.id} key={`${page.id}:${page.version}`}
page={page} page={page}
locale={locale} locale={locale}
busy={publish.isPending || reject.isPending} busy={publish.isPending || reject.isPending}
onPublish={(id, body) => publish.mutate({ id, body })} onPublish={(id, body) => publish.mutate({ id, expectedVersion: page.version, body })}
onReject={(id, note) => reject.mutate({ id, note })} onReject={(id, note) => reject.mutate({ id, expectedVersion: page.version, note })}
/> />
)) ))
)} )}

View File

@ -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);

View File

@ -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(';');
}); });

View File

@ -74,7 +74,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;

View File

@ -78,6 +78,7 @@ export type WikiContributionStatus = 'pending' | 'published' | 'rejected';
/** La même page, vue depuis la file de relecture : statut et décision compris. */ /** La même page, vue depuis la file de relecture : statut et décision compris. */
export interface WikiContributionReview extends Omit<WikiContribution, 'href'> { export interface WikiContributionReview extends Omit<WikiContribution, 'href'> {
version: number;
locale: string; locale: string;
status: WikiContributionStatus; status: WikiContributionStatus;
/** `null` tant que la page n'est pas publiée : le lien pointerait vers du vide. */ /** `null` tant que la page n'est pas publiée : le lien pointerait vers du vide. */
@ -106,11 +107,16 @@ export const getWikiContributions = (language: string) =>
export const getWikiReviewQueue = (status?: WikiContributionStatus) => export const getWikiReviewQueue = (status?: WikiContributionStatus) =>
get<WikiReviewQueue>(`/api/v1/admin/wiki-contributions${status ? `?status=${status}` : ''}`); get<WikiReviewQueue>(`/api/v1/admin/wiki-contributions${status ? `?status=${status}` : ''}`);
export const publishWikiContribution = (id: string, edits?: { section?: string; body?: string }) => export const publishWikiContribution = (
post<WikiContributionReview>(`/api/v1/admin/wiki-contributions/${id}/publish`, edits ?? {}); id: string,
edits: { expectedVersion: number; section?: string; body?: string }
) => post<WikiContributionReview>(`/api/v1/admin/wiki-contributions/${id}/publish`, edits);
export const rejectWikiContribution = (id: string, note?: string) => export const rejectWikiContribution = (id: string, expectedVersion: number, note?: string) =>
post<WikiContributionReview>(`/api/v1/admin/wiki-contributions/${id}/reject`, { note }); post<WikiContributionReview>(`/api/v1/admin/wiki-contributions/${id}/reject`, {
expectedVersion,
note,
});
export const getTradeConversations = () => export const getTradeConversations = () =>
get<TradeConversation[]>('/api/v1/trade-assistant/conversations'); get<TradeConversation[]>('/api/v1/trade-assistant/conversations');

View 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');
});
});

View File

@ -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;
} }

View 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)
);
});

View 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;
}

View 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('""');
});
});

View 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, '""')}"`;
}

View File

@ -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(',');
}); });

View File

@ -0,0 +1,49 @@
# Corrections des constats connus — 17 septembre 2026
Branche `check_secu`. Les correctifs et modifications de travail antérieurs sont conservés. Aucun commit, déploiement, paiement, accès à la production ou envoi d'email réel n'a été réalisé.
## SEC-07 — Secrets dans les journaux
Les derniers messages interpolant les tokens transporteur sont supprimés du service et du repository. La vérification d'invitation ne journalise plus le token ; l'exception « réservation introuvable » ne le recopie plus. Les erreurs de livraison SMTP et d'invitation sont remplacées par des erreurs génériques avant leur propagation, pour éviter qu'un appelant journalise un lien ou un mot de passe contenu dans une réponse fournisseur.
Les journaux HTTP utilisent des sérialiseurs à liste de champs autorisés : méthode et modèle de route côté serveur, statut de réponse et erreur générique. Ils n'incluent plus URL réelle, query string, paramètres, headers, cookies, corps ni détails d'erreur du pilote. Le filtre global et l'intercepteur de performance utilisent la même route statique. Pour une route non résolue, le journal indique `[unmatched route]`. Les erreurs HTTP délibérées conservent leur statut et leur message fonctionnel.
La relecture indépendante a identifié un chemin supplémentaire lorsque `DATABASE_LOGGING` est activé : TypeORM affiche ses paramètres SQL directement. Un logger TypeORM dédié enregistre désormais les catégories d'événements sans SQL, valeurs, paramètres ni erreurs brutes. Il est utilisé par l'API et la source de données CLI. Les niveaux de journalisation configurés restent respectés. Ce choix réduit volontairement le détail diagnostic pour empêcher la copie de credentials ; les codes HTTP, événements et références d'erreur restent disponibles.
Tests : sortie réelle Pino contenant des secrets synthétiques en URL, cookies, réponse et erreur ; chemins contrôleur/service/repository ; filtre d'erreur ; logger TypeORM avec logging activé et désactivé. Aucune copie de journaux de production n'est lue. Les anciens secrets déjà présents dans des journaux restent à révoquer lorsque nécessaire ; les copies historiques ne sont pas effacées par le correctif.
## OBS-01 — Erreur de tarif transformée en gratuité
Le comportement est confirmé au niveau du service avec des dépendances simulées. Avant correction, six cas de refus échouaient et cinq tarifs légitimes étaient conservés. Le service ne doit pas interpréter l'impossibilité de déterminer un tarif comme une offre gratuite.
Une erreur de lecture ou un montant invalide produit désormais une erreur HTTP 503 générique. La résolution a lieu avant tout upload en création et avant `booking.accept()` en acceptation. Aucun document ni changement de statut n'est enregistré lorsque le tarif est inconnu. Les valeurs positives et les tarifs explicites zéro/-1 sur mesure conservent leur comportement ; les autres montants négatifs et les valeurs non finies sont refusés.
Treize tests couvrent les échecs avant effets de bord, les montants valides et invalides, ainsi que la création payante en PENDING_PAYMENT et la création sur mesure en PENDING avec notification simulée. Cela démontre le comportement de panne et sa correction ; la capacité d'un attaquant à provoquer cette panne en production n'est pas établie.
## OBS-02 — Destination des webhooks
Aucun correctif de fonctionnement spéculatif n'est appliqué. Deux tests utilisant le véritable `I18nValidationPipe` et les types DTO déclarés par les contrôleurs confirment que création et modification rejettent actuellement une URL non validée avec HTTP 400. Cela ne prouve pas l'absence d'anciens webhooks dangereux dans la base ni d'autres voies d'écriture. Le risque reste documenté avant une future réparation des DTO.
## SEC-19 — Clé d'API littérale en préproduction
Une clé OpenAI était présente dans la modification locale de `docker/stack-portainer-preprod.yaml`. Seule sa valeur a été remplacée par une variable obligatoire `${OPENAI_API_KEY:?OPENAI_API_KEY must be supplied at deployment}` ; les autres modifications de ce fichier sont conservées. Le parsing YAML et l'absence de valeur littérale à cet emplacement sont vérifiés sans afficher la clé et sans charger de fichier `.env`.
**Action externe indispensable : révoquer/remplacer la clé chez le fournisseur et renseigner le secret de déploiement.** Aucune validité ni consommation du compte fournisseur n'est vérifiée. Une clé retirée du fichier peut encore exister dans une copie, une trace ou l'historique de l'outil.
## Autres constats et limites
Les correctifs SEC-01 à SEC-06 et SEC-08 à SEC-18 étaient déjà présents dans Git ou dans les modifications locales, comme indiqué dans le README. Les tests backend correspondants restent inclus dans la suite générale. Aucune nouvelle exécution navigateur/tableur ni revalidation frontend complète n'est revendiquée pour cette passe backend.
Restent hors des corrections locales : rotation SMTP (SEC-10), révocation des anciens liens transporteur/invitation (SEC-05/07), déploiement réel des correctifs et distribution de la CA PostgreSQL (SEC-16). Les scripts secondaires non couverts et l'audit externe des dépendances restent des lacunes de couverture, pas des vulnérabilités déclarées corrigées.
## Vérifications finales et résultats
- `npm test -- --runInBand` depuis apps/backend : **512 tests réussis, 5 ignorés ; 40 suites réussies, 1 ignorée**.
- `npm run build` depuis apps/backend : réussi.
- ESLint ciblé sur les fichiers backend modifiés/ajoutés : réussi.
- Parsing YAML local et contrôle de variable obligatoire OpenAI : réussis, sans interpolation des secrets ni contact fournisseur.
- `git diff --check -- apps/backend` et vérification des liens Markdown : réussis. Le fichier YAML conserve des espaces finaux préexistants à cette passe dans les autres changements utilisateur ; aucune correction globale de ce fichier n'a été effectuée.
Résultats : **fixed** pour les chemins locaux SEC-07, le comportement de panne OBS-01 et le retrait du littéral SEC-19 ; **no_change** pour la voie API de configuration OBS-02, dont le refus est confirmé. Le volet opérationnel de SEC-05/07/10/16/19 reste **blocked** faute d'accès/preuve externe : aucune révocation ni configuration de production n'est revendiquée.
Les tests d'erreur de frais échouaient avant la correction ; ils rejettent maintenant l'opération avant mutation/upload. Les contrôles de création payante et sur mesure passent. Les sorties Pino et TypeORM testées ne contiennent plus les secrets synthétiques alors que les événements, refus et usages légitimes restent observables. Les tests de niveau métier ne remplacent pas une recette de préproduction.

View File

@ -0,0 +1,52 @@
# Fusion de check_secu dans ia — sécurité, 22 septembre 2026
## Résultat et périmètre
Les corrections antérieures ont été commités sur `check_secu` dans `a0ac0379ebcad0cda28e177332d3271d450dfce0`. La branche `ia`, issue de `origin/ia` à `c35f3d7`, les a reçues dans le merge `ef0d7d5f670638a2dcce21f6aea8e169513b91ec`. Les conflits ont été résolus en conservant les contrôles de sécurité et les fonctionnalités IA.
Le scan `262023d3-7c39-4253-961a-a4175bdf5d72` examine exactement **a0ac037..ef0d7d5** : **110 fichiers de code/configuration modifiés**, répartis en 28 fichiers assistant/MCP/wiki, 40 fichiers authentification/démarrage et 42 autres fichiers réservation/interface/configuration. Les chemins de soutien nécessaires ont également été consultés. Quatre failles ont été confirmées ; les correctifs suivants sont destinés au commit de sécurité sur `ia` qui contient ce document.
| ID | Gravité historique | Correctif |
| --- | --- | --- |
| [SEC-20](wiki-propriete/wiki-propriete.md) | Moyenne | Propriété auteur/organisation, refus des pages publiées et écritures SQL conditionnelles |
| [SEC-21](wiki-version-revue/wiki-version-revue.md) | Moyenne | Version effectivement relue exigée et contrôle atomique des conflits |
| [SEC-22](identifiants-demo-preprod/identifiants-demo-preprod.md) | Élevée | Préproduction protégée, rotation des identifiants publics, garde avant le bootstrap historique |
| [SEC-23](bootstrap-identite/bootstrap-identite.md) | Élevée | Aucun octroi du rôle ADMIN à un compte existant sur la seule égalité de son email |
Les scripts de démarrage/migration utilisent aussi le logger SQL sécurisé et des messages d’échec génériques afin de ne pas journaliser les nouveaux hashes lors d’une erreur (complément SEC-07).
## Preuves et tests
Six assertions de régression échouaient sur le code fusionné vulnérable. Après correction :
- Backend : **734 tests réussis, 5 ignorés** ; 57 suites réussies et une suite ignorée.
- Parmi eux, **7 tests sur un PostgreSQL temporaire isolé** vérifient collisions de titres, propriété, version périmée, décisions concurrentes, rotation des identifiants et gardes bootstrap.
- Frontend : **191 tests réussis** ; vérification TypeScript réussie.
- Compilation backend et lint ciblé des fichiers TypeScript backend modifiés : réussis.
- Contrôle des espaces du patch sécurité : réussi ; les modifications utilisateur exclues du commit ne sont pas reformatées.
Une première exécution dans le bac à sable ne pouvait pas ouvrir les sockets locales (`EPERM`). La relance autorisée a réussi. Aucun serveur SMTP réel, service Stripe ou environnement de production n’a été utilisé. Le PostgreSQL de test utilise exclusivement un socket local et une base temporaire ; aucune migration de la base du projet n’a été exécutée.
Une revue indépendante du patch a identifié un cas résiduel : un bootstrap configuré avec une adresse différente des comptes de démonstration pouvait encore conserver le mot de passe public via la migration historique. Le garde préalable, la rotation de l’adresse configurée et un test Argon2 couvrent désormais ce chemin. Le test PostgreSQL a également permis de corriger l’interprétation du résultat UPDATE de TypeORM.
## Mise en service et compatibilité
Trois nouvelles migrations sont ajoutées, sans modifier les migrations historiques déjà appliquées :
- `GuardPendingLegacyAdminBootstrap1755999999999`, volontairement ordonnée avant le bootstrap historique encore en attente : refuse une adresse déjà occupée par un non-administrateur, exige une transaction et verrouille les utilisateurs pendant ce passage.
- `VersionWikiContributions1790000000003` : version positive initialisée à 1 sur les propositions existantes.
- `RotatePublicDemoCredentials1790000000004` : remplace uniquement les hashes correspondant au mot de passe public connu, conserve les mots de passe personnalisés et laisse le dernier administrateur actif. Un compte concerné doit récupérer/configurer un mot de passe sûr avant connexion ; le mot de passe aléatoire de remplacement n’est pas communiqué.
Les runners fournis imposent `migrationsTransactionMode: all`. La nouvelle interface wiki et l’API doivent être déployées ensemble : publication/rejet sans `expectedVersion` sont refusés, et une version obsolète reçoit 409. Une ancienne page ouverte doit être rechargée. Un auteur peut réviser sa proposition non publiée ; il ne peut plus remettre lui-même une page publiée en attente.
Si une adresse bootstrap est déjà utilisée par un non-administrateur, le déploiement doit choisir une adresse libre ou passer par une procédure administrative autorisée. Le service ne convertit pas automatiquement ce compte en administrateur.
## Limites et point de politique à confirmer
Cette passe couvre intégralement le **diff identifié**, pas tous les fichiers historiques du monorepo ni toutes les combinaisons d’exécution. Les cinq tests ignorés restent une limite de la suite. Les fichiers `.env` et `.env.*` n’ont pas été lus. L’audit de dépendances, les configurations effectives, les politiques réseau externes et le site en ligne ne sont pas vérifiés. Aucune révocation fournisseur ou publication distante n’a été effectuée.
**SEC-12 : politique modifiée par ia.** Le code IA permet explicitement la lecture d’un dossier individuel d’un collègue de la même organisation ; les listes et statistiques globales restent limitées aux rôles de gestion. Cette évolution intentionnelle ne permet pas d’affirmer que l’ancienne restriction « propriétaire uniquement » est toujours présente. La politique produit attendue doit être confirmée. L’interface qui demande une liste globale avec un rôle ordinaire peut recevoir un 403 ; le garde n’a pas été retiré pour contourner cette incompatibilité.
Les actions opérationnelles SEC-05, SEC-10 et SEC-19 restent ouvertes : anciens jetons distribués, secrets SMTP et clé API retirée du code ne sont pas automatiquement révoqués chez leurs fournisseurs. Le fichier `docker/stack-portainer-preprod.yaml` contient des modifications utilisateur conservées hors du nouveau commit ; la sauvegarde stash antérieure est conservée.
Aucun push, déploiement ou migration de production n’est inclus dans ce travail.

View File

@ -0,0 +1,43 @@
# Couverture réelle et angles morts
L'audit initial indique **95 fichiers suivis lus intégralement**, plus des lectures ciblées. Ce nombre ne représente pas la couverture de tout le monorepo. La passe documentaire ajoute des relectures ciblées ; elle n'a pas recalculé un pourcentage global et ne revendique pas de nouveau scan exhaustif réussi.
## Surfaces examinées
| Surface | Constats documentés | Ce qui reste hors de la preuve |
| --- | --- | --- |
| Connexion, récupération et sessions | SEC-01, SEC-03, SEC-08 | Reproduction navigateur complète, toutes les variantes OAuth et toutes les politiques de session du déploiement. |
| Organisations et utilisateurs | SEC-02, SEC-06, SEC-12, SEC-13 | Revue systématique de chaque route et de chaque matrice acteur/cible. |
| Notifications REST/WebSocket | SEC-03, SEC-04 | Mise à jour réelle PostgreSQL des critères historiques, tests à forte concurrence et déploiement multi-instance. |
| Réservations CSV et transporteurs | SEC-05, SEC-06, SEC-09, SEC-12, OBS-01 | Toutes les transitions, effets secondaires, reprise après erreur et gestion réelle des fichiers. |
| Stripe, licences et offres | SEC-11, SEC-17, SEC-18 | Webhooks réels, ordre et répétition d'événements, conditions de course en base réelle, cohérence de données déjà enregistrées. |
| Logs et credentials | SEC-07, SEC-10 | Contenu réel des journaux, ACL, rétention, copies externes et révocations fournisseur. |
| TLS SMTP/PostgreSQL | SEC-15, SEC-16 | Certificats et topologies en ligne, scripts de maintenance secondaires, validation effective de chaque secret de déploiement. |
| Exports CSV | SEC-14 | Tests d'ouverture dans les différents tableurs et configurations des utilisateurs. |
| MCP et assistant | SEC-18 pour droits déclarés/actuels | Pas d'outil premium identifié dans le catalogue courant ; toutes les nouvelles capacités et leurs évolutions nécessitent leur propre vérification. |
| Webhooks sortants | OBS-02 | Contrôle d'URL réellement accessible depuis une voie d'écriture, anciennes lignes en base et politique DNS/redirections. |
## Zones à examiner en priorité après ce registre
1. **Logs transporteur actuels** : compléter SEC-07 par le cycle de vie des tokens, l'accès aux journaux et la fenêtre précédant la décision ; le code d'interpolation est confirmé.
2. **Erreurs de facturation** : valider OBS-01 sur un scénario local où seule la récupération des frais échoue ; distinguer une réservation persistée sans paiement d'une panne empêchant toute sauvegarde.
3. **Documents et stockage** : comparer les buckets provisionnés, ceux utilisés par les adaptateurs, les ACL et les URLs de téléchargement. La présence de noms différents n'est pas une preuve que des documents sont publics. Aucun bucket de production n'a été interrogé.
4. **Scripts et migrations** : recenser les clients PostgreSQL hors démarrage principal, puis leurs politiques TLS ; les migrations appliquées ne doivent pas être modifiées pour documenter un défaut.
5. **Adaptateurs transporteurs, pages et composants restants** : poursuivre la lecture intégrale, tracer les données externes jusqu'aux rendus et requêtes sortantes. Leur présence dans le monorepo ne signifie pas qu'ils ont tous été audités.
6. **Dépendances** : audit des versions réellement verrouillées, avec séparation runtime/développement et examen de la portée de chaque avis. Aucun nombre de vulnérabilités npm n'est connu à cette date.
7. **Infrastructure réelle** : plafonds HTTP, accès réseau à la base et aux logs, CA distribuée, comptes de service et état des rotations. La configuration du dépôt ne suffit pas pour conclure sur le site en ligne.
## Faux positifs et confusions à éviter
- Les fichiers et descriptions historiques mentionnent localStorage, mais le contexte de connexion actif étudié utilise des cookies HttpOnly. Ne pas faire de l'ancien mécanisme un constat courant sans tracer son utilisation.
- Les requêtes paramétrées examinées dans recherche, GDPR et conversations n'ont pas permis d'établir une injection SQL. Ce constat limité ne couvre pas chaque requête du dépôt.
- L'export GDPR examiné exclut les hashes de mot de passe, TOTP et clés ; cela ne prouve pas à lui seul une conformité juridique globale.
- Les contrôles de statut, de mot de passe et d'appartenance documentaire limitent la portée d'un token transporteur. Une fuite de token de décision n'est pas une preuve de téléchargement sans mot de passe.
- Les routes webhook et l'appel HTTP sortant ne suffisent pas à établir un SSRF : la voie d'enregistrement est un élément manquant, détaillé dans OBS-02.
- Le statut historique « no_issue_found » d'une surface signifie seulement qu'aucune faille n'avait été retenue dans les chemins examinés. Il ne vaut pas garantie, notamment après les constats ultérieurs sur les droits d'abonnement.
## Dépendances et services externes
La précédente tentative `npm audit` a échoué sur l'accès au registre ; la revue automatique a ensuite refusé l'envoi externe des noms et versions sans autorisation explicite. Cette autorisation reste non reçue. Aucun contournement ni nouvel envoi n'est effectué pour ce dossier.
Aucun test du site public, paiement, envoi d'email, connexion à la base réelle ou lecture des fichiers `.env` n'est inclus. Les constats locaux ne permettent pas d'attester que le site en ligne expose aujourd'hui chacun des comportements historiques.

26
audit_security/JOURNAL.md Normal file
View File

@ -0,0 +1,26 @@
# Journal de poursuite de l'audit
## 15 septembre 2026 — Échec de la tentative approfondie lancée le 14 septembre
Objectif demandé : examiner intégralement le projet et poursuivre la documentation, sans nouveaux correctifs applicatifs. Périmètre demandé : tout le dépôt, avec exclusion explicite des fichiers `.env` et `.env.*`, analyses hors ligne et aucune opération de production.
Le résultat terminal du coordinateur contient exactement :
> 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 Sep 15th, 2026 12:20 AM.
> This is a terminal failure of this logical Deep Scan; no successful discovery manifest was returned.
L'heure mentionnée est celle rapportée par le service ; aucune vérification d'un rétablissement actuel du quota n'est effectuée. Ce message ne signifie pas qu'une analyse réussie a eu lieu jusqu'à cette heure.
### Résultats et couverture
- **Constats déjà conservés :** 18 fiches SEC et 2 observations OBS dans le README, issus des passes antérieures.
- **Nouveaux constats validés de cette tentative :** aucun résultat exploitable retourné ; cela ne signifie pas absence de vulnérabilité.
- **Candidats éventuellement sauvegardés par le coordinateur :** inconnus. La réponse d'échec ne fournit aucun identifiant de scan, alors que la lecture de son contexte en exige un. Aucun identifiant d'un ancien audit n'est substitué.
- **Couverture supplémentaire mesurée :** indisponible. Le nombre de fichiers entièrement examinés n'est pas augmenté.
- **Consommation de tokens de cette tentative :** mesure indisponible, ni zéro ni estimation.
### Conséquence
Le coordinateur et le workflow Deep Security Scan imposent de ne pas relancer cette tentative terminale, de ne pas démarrer de remplacement dans cette réponse et de ne pas finaliser un scan sans manifeste réussi. Aucun scan n'est donc déclaré complet. Seul cet état administratif est ajouté au dossier ; aucune nouvelle fiche de vulnérabilité ni correction applicative n'est produite.
La prochaine poursuite doit tenir compte de ce blocage et conserver les limites de [COUVERTURE.md](COUVERTURE.md). L'analyse externe des dépendances demeure par ailleurs soumise à l'autorisation déjà en attente ; cet échec de quota ne change pas cette restriction.

View File

@ -0,0 +1,37 @@
# Méthode, versions et lecture des preuves
Ce dossier est un registre documentaire de sécurité Xpeditis établi le **14 septembre 2026**. Il rassemble les constats connus, leur état actuel et les questions ouvertes. Il n'affirme pas recenser toutes les vulnérabilités possibles du dépôt.
## Références de code
| Référence | Signification |
| --- | --- |
| `8446f879b676b303fdb2891388f88ff7e43f5fea` | Snapshot préaudit, issu de `preparation_prod`, utilisé pour relire les 14 constats initiaux et les deux constats TLS. |
| `c09b8be9ae1d399e2c374750bfb161e7a4ad0015` | HEAD de `check_secu` pendant la rédaction. Contient les correctifs des premières passes. |
| État local du 14 septembre | Contient en plus les correctifs de rattachement Stripe et de droits des abonnements, leurs tests et les ajustements de quota. Ces changements applicatifs étaient déjà présents avant la demande de documentation et restent non commités. |
Aucun tag n'est présent dans l'inventaire Git local. Les numéros de package ne permettent pas de connaître la release déployée. L'introduction exacte de chaque problème, les branches publiées affectées et d'éventuels backports ne sont pas établis ; la version préaudit est la plus ancienne vérifiée ici, pas nécessairement la première vulnérable. La comparaison entre les deux snapshots établit l'état avant/après des chemins cités, sans inventer une chronologie de releases.
## Nature des preuves
- **Code vérifié** : chemin décisif relu dans le snapshot vulnérable et comparaison avec le fichier courant. Les extraits historiques portent leur révision ; leurs lignes ne doivent pas être recherchées telles quelles dans le fichier corrigé.
- **Tests locaux observés lors des passes précédentes** : refus, maintien des usages autorisés, simulations de services et certaines connexions HTTP/TLS sur loopback. Ils n'attestent pas qu'un incident a existé.
- **Analyse statique sans exploitation exécutée** : mécanisme établi à partir des appels et contrôles, sans observer le résultat sur un produit complet ou la production.
- **Observation à valider** : comportement problématique possible, mais prérequis attaquant ou parcours complet non démontré. Les fiches OBS ne sont pas additionnées aux failles confirmées.
- **Inconnu en production** : version déployée, rotation des credentials, copies de logs, ACL de stockage, limites proxy ou règles réseau non vérifiées.
Les références d'origine comportaient quelques lignes devenues inexactes ou trop larges. Les fiches privilégient les fonctions et les extraits effectivement relus ; elles ne reprennent pas automatiquement tous les numéros du fichier findings.json. Le comportement de dépendances inspecté dans l'audit initial est distingué d'une nouvelle reproduction dynamique.
## Gravité et statut
Une gravité qualifie le mécanisme vulnérable sous ses prérequis ; elle ne prouve pas qu'il reste accessible dans la version courante. Les gravités élevées/moyennes/faibles des 14 constats initiaux sont conservées avec leurs limites. Les constats TLS et Stripe supplémentaires sont qualifiés sans attribuer de CVSS numérique ni de CVE.
« Corrigé dans le code » ne signifie pas « déployé », « exploité », « tous les anciens secrets révoqués » ou « testé contre chaque infrastructure ». SEC-07 reste partiellement corrigé en raison des tokens transporteur encore journalisés. SEC-05 et SEC-10 nécessitent une vérification opérationnelle des credentials antérieurement exposés.
## Limites de cette passe documentaire
La demande actuelle autorise la documentation détaillée, pas de nouveaux correctifs applicatifs. Aucun code applicatif n'est modifié pour ces fiches, aucun déploiement ni attaque de production n'est lancé. Les fichiers `.env` et `.env.*` ne sont pas lus. Aucun secret ni contenu de journal réel n'est recopié.
La rédaction déléguée a échoué sur une limite d'usage. Les fiches constituent donc une compilation relue directement, sans nouvelle revue indépendante par fiche. Les revues indépendantes des correctifs antérieurs restent celles décrites dans les comptes rendus existants ; elles ne doivent pas être présentées comme une validation indépendante de ce dossier.
Sources historiques : [rapport initial](../docs/security/check-secu/report.md), [constats structurés](../docs/security/check-secu/findings.json), [corrections initiales](../docs/security/check-secu/REMEDIATION.md), [10 septembre](../docs/security/check-secu/REMEDIATION-2026-09-10.md), [14 septembre](../docs/security/check-secu/REMEDIATION-2026-09-14.md). Les mentions « non commité » des anciens comptes rendus décrivent leur date et ne priment pas sur le tableau de versions ci-dessus.

79
audit_security/README.md Normal file
View File

@ -0,0 +1,79 @@
# Audit de sécurité — Xpeditis
Ce dossier rassemble **23 constats de sécurité connus**, chacun expliqué dans une fiche, et **2 observations à valider**. Il décrit l'état du code, mis à jour le **22 septembre 2026**, sur la branche `ia`.
**Ce n'est pas une liste exhaustive de toutes les failles possibles ni une attestation de sécurité de la production.** Une grande partie des constats a déjà été corrigée dans le code ; les fiches expliquent le problème historique, la preuve, les limites et le risque restant. Après la passe documentaire, les corrections restantes ont été demandées et traitées le 17 septembre ; voir le [compte rendu](CORRECTIONS-2026-09-17.md).
## Historique de la poursuite — 15 septembre 2026
La nouvelle tentative d'audit approfondi de tout le dépôt a échoué sur une limite d'usage après trois workers de découverte infructueux. Aucun manifeste réussi ni identifiant de scan n'a été retourné ; les éventuels résultats intermédiaires ne sont donc pas consultables depuis cette réponse. **La couverture reste partielle, sans nouveau constat validé à ajouter.** Les 18 constats et 2 observations ci-dessous restent ceux documentés précédemment. Voir le [journal de poursuite](JOURNAL.md) pour l'erreur exacte et les limites.
## Fusion et corrections — 22 septembre 2026
Les correctifs précédents ont été commités (`a0ac037`), puis fusionnés dans `ia` (`ef0d7d5`). La revue des 110 fichiers de code/configuration modifiés par cette fusion a confirmé quatre nouvelles failles, corrigées et détaillées dans le [compte rendu](CORRECTIONS-FUSION-IA-2026-09-22.md). Cette couverture du diff ne constitue pas un nouvel audit exhaustif de tout le dépôt.
## Corrections — 17 septembre 2026
SEC-07 est corrigé dans le code local, y compris les logs HTTP/SMTP/TypeORM. OBS-01 refuse désormais un tarif inconnu avant effets de bord ; ses cas de panne et ses contrôles légitimes sont testés. OBS-02 reste une observation : les tests du pipe confirment le refus actuel de création/modification. SEC-19 documente une clé d'API retirée de la configuration locale, dont la révocation externe reste nécessaire. Aucun déploiement n'est effectué.
## À regarder d'abord
- **SEC-07 : correction locale appliquée.** Les credentials ne doivent plus être recopiés par les chemins de journalisation corrigés ; les secrets déjà présents dans les anciens logs restent à traiter.
- **Actions opérationnelles non vérifiées : SEC-05, SEC-10 et SEC-19.** La suppression d'un token dans une réponse ou d'un secret SMTP dans le dernier fichier ne révoque pas les copies déjà distribuées.
- **Version et configuration en ligne inconnues : SEC-15/16/17/18.** Les correctifs TLS principaux sont suivis dans Git ; les correctifs Stripe et droits d’abonnement sont commités dans `a0ac037` et fusionnés dans `ia`. Aucun déploiement n'est confirmé.
- **OBS-01 corrigé ; OBS-02 à surveiller.** La gestion du tarif inconnu est corrigée et testée. Une éventuelle voie d’écriture de destination webhook reste à établir.
## Index des constats
La gravité décrit le comportement vulnérable avant correction. Elle ne doit pas être lue comme le risque résiduel du site en ligne. Les IDs sont stables dans ce dossier ; les chemins ouvrent les fiches détaillées.
| ID | Problème | Gravité | État actuel |
| --- | --- | --- | --- |
| SEC-01 | [La redirection de connexion permet une XSS DOM](xss-redirection-connexion/xss-redirection-connexion.md) | Élevée | Corrigé dans Git ; déploiement inconnu |
| SEC-02 | [Un manager peut modifier une autre organisation](modification-inter-organisations/modification-inter-organisations.md) | Élevée | Corrigé dans Git ; déploiement inconnu |
| SEC-03 | [Les WebSockets acceptent des sessions révoquées ou désactivées](sessions-websocket/sessions-websocket.md) | Moyenne | Corrigé dans Git ; déploiement inconnu |
| SEC-04 | [Un membre peut marquer toutes les notifications comme lues](notifications-propriete-et-criteres/notifications-propriete-et-criteres.md) | Moyenne | Corrigé dans Git ; déploiement inconnu |
| SEC-05 | [Le client reçoit le jeton de réponse du transporteur](jeton-transporteur-dans-reponses/jeton-transporteur-dans-reponses.md) | Moyenne | Réponses corrigées ; anciens tokens à traiter |
| SEC-06 | [VIEWER peut créer et modifier des réservations](mutations-role-viewer/mutations-role-viewer.md) | Moyenne | Corrigé dans Git ; déploiement inconnu |
| SEC-07 | [Les logs contiennent mots de passe et invitations](secrets-dans-les-journaux/secrets-dans-les-journaux.md) | Moyenne | Correctif local testé ; anciennes copies à traiter |
| SEC-08 | [Le changement de mot de passe conserve les anciennes sessions](sessions-apres-reset-mot-de-passe/sessions-apres-reset-mot-de-passe.md) | Moyenne | Corrigé dans Git ; déploiement inconnu |
| SEC-09 | [Les téléversements ne bornent pas la mémoire utilisée](televersements-memoire/televersements-memoire.md) | Moyenne | Corrigé dans Git ; déploiement inconnu |
| SEC-10 | [Une clé SMTP figure dans un fichier suivi](secret-smtp-versionne/secret-smtp-versionne.md) | Moyenne | Littéral retiré ; rotation fournisseur non vérifiée |
| SEC-11 | [La résiliation peut conserver les avantages payants](resiliation-bloquee-par-licences/resiliation-bloquee-par-licences.md) | Moyenne | Corrigé dans Git ; déploiement inconnu |
| SEC-12 | [Les dossiers des collègues sont accessibles sans rôle de gestion](lecture-dossiers-collegues/lecture-dossiers-collegues.md) | Faible | Politique modifiée par ia : voir le compte rendu de fusion |
| SEC-13 | [Un manager peut rétrograder un administrateur de son organisation](manager-modifie-administrateur/manager-modifie-administrateur.md) | Faible | Corrigé dans Git ; déploiement inconnu |
| SEC-14 | [Les exports CSV conservent les formules injectées](injection-formules-csv/injection-formules-csv.md) | Faible | Corrigé dans Git ; déploiement inconnu |
| SEC-15 | [SMTP : identité du serveur non vérifiée et STARTTLS facultatif](smtp-tls-non-verifie/smtp-tls-non-verifie.md) | Moyenne | Code corrigé ; configuration effective à confirmer |
| SEC-16 | [PostgreSQL : TLS incohérent et certificat non authentifié](postgresql-tls-incoherent/postgresql-tls-incoherent.md) | Moyenne | Chemins principaux corrigés ; CA/scripts à vérifier |
| SEC-17 | [Stripe : session Checkout non liée à son organisation](stripe-session-organisation/stripe-session-organisation.md) | Moyenne | Corrigé dans Git (a0ac037) ; déploiement inconnu |
| SEC-18 | [Droits payants conservés sur un abonnement inactif](droits-abonnements-inactifs/droits-abonnements-inactifs.md) | Moyenne | Corrigé dans Git (a0ac037) ; déploiement inconnu |
| SEC-19 | [Clé d’API littérale en préproduction](cle-api-preproduction/cle-api-preproduction.md) | Moyenne, validité inconnue | Littéral retiré ; révocation externe nécessaire |
| SEC-20 | [Écrasement et retrait de contributions wiki étrangères](wiki-propriete/wiki-propriete.md) | Moyenne | Corrigé sur ia ; déploiement inconnu |
| SEC-21 | [Publication d’une version non relue par l’administrateur](wiki-version-revue/wiki-version-revue.md) | Moyenne | Corrigé sur ia ; déploiement inconnu |
| SEC-22 | [Comptes de démonstration utilisables en préproduction](identifiants-demo-preprod/identifiants-demo-preprod.md) | Élevée | Corrigé sur ia ; déploiement inconnu |
| SEC-23 | [Promotion administrative par récupération d’une adresse bootstrap](bootstrap-identite/bootstrap-identite.md) | Élevée | Corrigé sur ia ; déploiement inconnu |
## Suivi des observations initiales
| ID | Analyse | Élément manquant |
| --- | --- | --- |
| OBS-01 | [Frais ramenés à zéro après erreur de lecture de l'abonnement](frais-erreur-abonnement/frais-erreur-abonnement.md) | Panne confirmée au service ; correctif local avec 13 tests. Contrôle de la panne par un attaquant non établi. |
| OBS-02 | [Destinations webhook et risque de requêtes internes](webhooks-destination-sortante/webhooks-destination-sortante.md) | Deux tests confirment le refus du pipe ; autres voies et données anciennes inconnues. |
Ces observations ne sont pas ajoutées aux constats SEC. Une lacune de couverture, une dépendance non analysée ou une configuration de production inconnue n'est pas automatiquement une vulnérabilité.
## Comment lire les fiches
Chaque fiche SEC expose le scénario, les droits nécessaires, le chemin dans le code, les contrôles insuffisants, l'impact étroit, les preuves disponibles et l'état de correction. Les titres techniques suivent un format commun ; le contenu est en français. Les extraits historiques sont explicitement distingués des liens vers le code courant.
- [METHODOLOGIE.md](METHODOLOGIE.md) : snapshots Git, statuts, gravité et nature des preuves.
- [COUVERTURE.md](COUVERTURE.md) : zones examinées, angles morts, hypothèses écartées et ordre de poursuite de l'analyse.
- [VALIDATION.md](VALIDATION.md) : tests déjà observés, limites et commandes locales reproductibles.
La dernière suite backend du 17 septembre compte **512 tests réussis et 5 ignorés**, avec compilation et lint ciblé réussis. Ce résultat ne signifie pas que tout le monorepo a été audité. L'audit de dépendances reste non réalisé ; aucun résultat npm ni CVE n'est inventé.
## Maintenance du registre
Conserver un ID pour chaque cause, mettre à jour son statut avec une preuve datée et distinguer toujours correction locale, commit et déploiement. Lorsqu'un nouveau chemin révèle la même cause, compléter la fiche existante, comme pour les logs transporteur de SEC-07. Une hypothèse devient un constat seulement après vérification de son entrée contrôlable, des contrôles traversés et de son effet protégé.
Ne jamais joindre de mot de passe, token réel, clé SMTP, fichier `.env` ou journal contenant un secret à ces fiches. Les documents décrivent les champs et opérations nécessaires à la vérification, pas leurs valeurs de production.

View File

@ -0,0 +1,46 @@
# Preuves disponibles et reproductibilité
## Résultats déjà observés
Ces résultats regroupent les passes antérieures et la reprise des corrections du 17 septembre.
| Date | Vérification | Résultat et limite |
| --- | --- | --- |
| 9 septembre | Correctifs initiaux, tests ciblés backend/frontend | Le compte rendu initial détaille les contrôles par constat ; aucun test d'exploitation du site en ligne. |
| 10 septembre | Suite backend complète | 451 réussis, 5 ignorés ; TLS/HTTP sur serveurs locaux, services métier simulés. |
| 14 septembre | Suite backend complète après les correctifs Stripe et droits | **491 réussis, 5 ignorés ; 36 suites réussies, 1 ignorée.** |
| 14 septembre | Compilation backend et lint ciblé | Réussis sur l'état applicatif préalable à ce dossier. |
| 14 septembre | Stripe binding avant correction | Deux cas de refus échouaient parce que GOLD était enregistré ; après correction, les six cas ciblés passent. |
| 14 septembre | Matrice domaine/garde avant correction | 12 cas de refus échouaient, 7 contrôles légitimes passaient ; les 19 passent après correction. |
| 17 septembre | Suite backend complète après corrections des logs et frais | **512 réussis, 5 ignorés ; 40 suites réussies, 1 ignorée.** |
| 17 septembre | Compilation, lint ciblé, parsing YAML, liens documentaires | Réussis ; aucune connexion à la production. |
Les cinq tests ignorés ne sont pas comptés comme réussis. Les chiffres de suites unitaires ne constituent pas une couverture de code mesurée ni une réussite des tests d'intégration contre PostgreSQL/Redis réels.
## Principaux artefacts
Les liens précis figurent dans les fiches. Ils couvrent notamment :
- **Propriété et rôles** : refus hors organisation, refus VIEWER en création et lecture conservée, refus d'un USER sur la liste globale, protection des cibles ADMIN.
- **Sessions** : type access sur WebSocket, compte actif, expiration, changement de mot de passe invalidant les anciens tokens et maintien des usages légitimes.
- **Notifications** : rejet des objets/tableaux/UUID invalides et prédicat `{id,user_id}` sur un repository simulé. Pas de preuve d'une mise à jour globale exécutée sur une vraie base.
- **Transporteur** : absence du token dans le mapper de réponse, données métier conservées. Le test ne vérifie pas l'effacement des anciens tokens ni les logs résiduels SEC-07.
- **CSV** : encodage des préfixes de formule et taille excessive rejetée avant le service. Pas d'ouverture réelle dans Excel ni test de saturation mémoire.
- **TLS** : véritables handshakes locaux PostgreSQL avec certificat de test ; options SMTP et refus STARTTLS avant AUTH sur serveur local. Pas de vérification du certificat SMTP du fournisseur.
- **Abonnements** : annulation malgré licences surnuméraires, erreur webhook non acquittée comme succès, liaison Checkout à l'organisation, droits courants et révocation d'accès API après suspension.
## Relancer des tests dans un environnement de développement
Les commandes ci-dessous sont des recettes pour les tests existants, à lancer depuis la racine du dépôt sur un environnement isolé déjà configuré. Elles ne sont pas des exploitations de production et n'ont pas été exécutées de nouveau pour ce README.
```sh
npm test --prefix apps/backend -- --runInBand subscription-sync.security.spec.ts subscription-access.security.spec.ts
npm test --prefix apps/backend -- --runInBand api-keys-entitlement.security.spec.ts mcp-entitlement.security.spec.ts
npm test --prefix apps/backend -- --runInBand organizations.controller.spec.ts users.security.spec.ts notification.security.spec.ts
```
Certains tests HTTP/TLS ouvrent des ports sur loopback et demandent un environnement autorisant cette opération. Ne pas remplacer les doubles de Stripe, SMTP ou de base par des identifiants réels pour obtenir une prétendue preuve plus forte. Ne pas lancer automatiquement les configurations frontend chargeant des fichiers `.env` sans respecter les restrictions du projet.
## Vérifications propres au dossier
La vérification documentaire porte sur l'existence des fiches et liens locaux, la séparation entre constats et observations, les extraits relus et l'absence de valeurs de secrets copiées. Aucun validateur formel de rapports fourni par le projet n'a été utilisé. Aucune nouvelle relecture indépendante des fiches n'a pu être menée après la limite d'usage de délégation.

View File

@ -0,0 +1,29 @@
# SEC-23 — Promotion administrative par récupération d’une adresse bootstrap
Date : 22 septembre 2026. Gravité historique : **Élevée** (CWE-269 / CWE-287). Statut : corrigé dans le code de `ia`, déploiement non vérifié.
## Scénario et conditions
Après suppression/anonymisation légitime du compte bootstrap, son adresse peut redevenir libre. Un attaquant l’enregistre et se connecte avant un redémarrage. Le nouveau bootstrap retrouvait le compte par email, lui attribuait ADMIN et conservait son mot de passe lorsqu’une connexion était déjà enregistrée.
## Impact et limites
Élévation jusqu’au rôle administrateur global sans preuve de possession de l’identité opérateur. Nécessite une adresse bootstrap configurée libérée puis revendiquée avant le redémarrage. Il ne s’agit pas d’une prise de contrôle démontrée sur le site déployé.
## Preuves dans la version vulnérable
Révision immuable examinée : `ef0d7d5f670638a2dcce21f6aea8e169513b91ec`. Les numéros suivants désignent cette révision, pas les lignes du correctif :
`apps/backend/src/infrastructure/persistence/typeorm/admin-bootstrap.service.ts:100,195,230 ; apps/backend/src/application/auth/auth.service.ts:213`
Le traçage des entrées, contrôles et écritures a été complété par des régressions locales : six assertions de sécurité échouaient avant correction, couvrant les quatre constats de cette passe. Il ne s’agit pas d’une reproduction HTTP sur la production.
## Correction
Aucun compte existant non-administrateur ne peut être promu ou réactivé par ce mécanisme, même avec le drapeau de réinitialisation. Le compte est verrouillé et la mise à jour exige déjà le rôle ADMIN. La création sur une adresse libre reste possible. Une nouvelle migration de garde, ordonnée avant le bootstrap historique non encore appliqué, refuse une adresse occupée par un non-administrateur et maintient un verrou jusqu’à la fin de la transaction globale.
## Vérification
Régressions avec et sans réinitialisation, vérification du refus au démarrage sur PostgreSQL et refus du bootstrap historique encore en attente. Les migrations déjà appliquées ne sont pas modifiées ; les trois runners utilisent le mode transactionnel all.
Voir le [compte rendu et les limites de couverture](../CORRECTIONS-FUSION-IA-2026-09-22.md). Aucun fichier `.env` n’a été lu, aucun service de production n’a été contacté, aucune migration n’a été appliquée à la base du projet pendant ces tests.

View File

@ -0,0 +1,19 @@
# SEC-19 — Clé d'API littérale dans une configuration de préproduction
**Découvert le 17 septembre 2026 dans une modification locale. Gravité potentielle : moyenne, sous réserve de validité et de permissions du credential.**
## Constat
`docker/stack-portainer-preprod.yaml` contenait une valeur littérale pour `OPENAI_API_KEY`. Une personne obtenant ce fichier ou sa copie pouvait obtenir le credential sans disposer de l'autorité d'administration du fournisseur. La clé n'est pas reproduite ici. Aucune requête d'authentification ni vérification de sa validité n'a été effectuée.
L'impact possible est l'utilisation de l'API dans les limites des permissions et quotas accordés à cette clé. Aucun accès aux autres données du fournisseur, aucune consommation frauduleuse et aucun incident réel ne sont établis. La présence dans une modification locale ne prouve pas une publication Git ou un déploiement.
## Correction locale
La valeur est remplacée par une variable obligatoire de déploiement. Le parseur YAML charge correctement le fichier, et un contrôle vérifie que l'emplacement ne contient plus de valeur littérale. Les autres changements utilisateur du fichier sont conservés. La configuration échouera volontairement si la variable n'est pas fournie au déploiement.
## Action restante
Révoquer la clé exposée chez le fournisseur, générer un remplacement selon les permissions nécessaires et renseigner le stockage de secrets du déploiement. Cette opération n'est pas effectuée depuis le dépôt et la clé ne doit pas être recopiée dans un ticket ou un rapport. La suppression du littéral ne détruit pas les copies déjà produites.
Source : [configuration](../../docker/stack-portainer-preprod.yaml). Voir le [compte rendu de correction](../CORRECTIONS-2026-09-17.md).

View File

@ -0,0 +1,56 @@
# SEC-18 — Droits payants conservés sur un abonnement inactif
**Gravité : Moyenne, avant correction.**
**État au 14 septembre 2026 :** Correctif local non commité au début de cette rédaction ; déploiement inconnu.
## Executive Summary
Un utilisateur d'une organisation dont l'abonnement est passé dans un état sans droits conserve une offre facturée GOLD ou PLATINIUM en base. Il utilise une fonctionnalité payante ou une clé API déjà créée. Il n'a pas besoin de modifier Stripe ni de falsifier son rôle.
Le commit `c09b8be` contient encore le comportement vulnérable ; le correctif examiné est dans les modifications locales du 14 septembre. Aucun tag local ni version de production vérifiée ne permet d'annoncer une première release affectée ou une release déployée corrigée. La validation combine relecture du source et tests locaux documentés ; aucun incident réel n'est affirmé.
## Background
La frontière de sécurité est celle décrite par les prérequis ci-dessus. Le paramétrage fourni par le dépôt ne permet pas de connaître la topologie et les valeurs effectivement en ligne. Les preuves disponibles doivent donc être lues séparément des conditions de déploiement restant à vérifier.
## Vulnerability Details
`SubscriptionStatus.allowsAccess` exclut UNPAID, PAUSED, INCOMPLETE, INCOMPLETE_EXPIRED et CANCELED. Mais `Subscription.hasFeature` consultait uniquement props.plan ; plusieurs consommateurs lisaient directement subscription.plan pour les quotas et les frais. `FeatureFlagGuard` acceptait aussi un tableau planFeatures présent sur request.user avant la lecture en base. Le JwtStrategy HTTP courant ne transporte pas nécessairement ces claims, tandis que d'autres contextes d'authentification peuvent en disposer : la branche de confiance de claims est un défaut défensif confirmé, pas une preuve que toute requête JWT normale exploite ce raccourci. La branche DB était elle-même insuffisante puisqu'elle ignorait le statut. L'API key service, le JWT émis, l'aperçu et le résolveur MCP partageaient cette confusion entre offre facturée et droits actuels.
Sources courantes, fonctions et tests concernés :
- [apps/backend/src/domain/entities/subscription.entity.ts](../../apps/backend/src/domain/entities/subscription.entity.ts)
- [apps/backend/src/domain/value-objects/subscription-status.vo.ts](../../apps/backend/src/domain/value-objects/subscription-status.vo.ts)
- [apps/backend/src/application/guards/feature-flag.guard.ts](../../apps/backend/src/application/guards/feature-flag.guard.ts)
- [apps/backend/src/application/api-keys/api-keys.service.ts](../../apps/backend/src/application/api-keys/api-keys.service.ts)
- [apps/backend/src/application/auth/auth.service.ts](../../apps/backend/src/application/auth/auth.service.ts)
- [apps/backend/src/application/mcp/mcp.controller.ts](../../apps/backend/src/application/mcp/mcp.controller.ts)
- [apps/backend/src/application/guards/subscription-access.security.spec.ts](../../apps/backend/src/application/guards/subscription-access.security.spec.ts)
- [apps/backend/src/application/api-keys/api-keys-entitlement.security.spec.ts](../../apps/backend/src/application/api-keys/api-keys-entitlement.security.spec.ts)
- [apps/backend/src/application/mcp/mcp-entitlement.security.spec.ts](../../apps/backend/src/application/mcp/mcp-entitlement.security.spec.ts)
- [apps/backend/src/application/controllers/csv-bookings.security.spec.ts](../../apps/backend/src/application/controllers/csv-bookings.security.spec.ts)
Pour comparer au snapshot vulnérable, consulter ces mêmes chemins dans la révision citée, sans supposer que les numéros de lignes actuels correspondent à l'ancienne version.
## Exploitability Analysis
Conservation de fonctionnalités, clés API et avantages de quota/frais au-delà de l'état qui doit les autoriser. La frontière est celle de l'abonnement de sa propre organisation ; aucune élévation de rôle ou lecture inter-tenant n'est nécessaire. Le catalogue MCP actuel n'impose pas de fonctionnalité payante à ses outils : son défaut porte sur la résolution/annonce de l'offre et la cohérence du futur contrôle, pas sur un outil premium identifié et exploité aujourd'hui.
Le problème ne requiert pas de supprimer les contrôles métier ou cryptographiques voisins. Il exploite précisément la différence entre le contrôle attendu et celui effectivement exécuté. Les contre-exemples ci-dessous précisent ce que les tests isolent ; ils ne constituent pas un test de pénétration du site en ligne.
## Proof of Concept
Avant correction, la matrice domaine/garde présentait 12 refus attendus qui échouaient et 7 contrôles légitimes réussis. Les 19 passent ensuite. Huit tests API vérifient refus de création et perte d'usage après suspension, ainsi que ACTIVE/TRIALING/PAST_DUE. Six tests MCP vérifient l'offre courante de whoami et l'exception ADMIN. Le quota Bronze après suspension est testé au contrôleur avec la véritable entité. Les dépôts et Stripe restent simulés.
Les artefacts sont déjà dans les fichiers de test liés ci-dessus. Aucun faux journal d'exploitation ni nouvelle commande d'attaque de production n'est fourni. Voir [VALIDATION.md](../VALIDATION.md) pour le périmètre et les résultats consolidés.
## Remediation
`Subscription.accessPlan` conserve l'offre payante seulement pour ACTIVE, TRIALING et PAST_DUE, conformément à la grâce existante ; les autres états donnent Bronze. Le plan de facturation reste persisté. hasFeature, quota et frais utilisent accessPlan ; les consommateurs ont été alignés. Le garde consulte les droits actuels plutôt qu'une déclaration ancienne. Les exceptions ADMIN déjà présentes restent inchangées et aucune exception ADMIN n'a été ajoutée aux clés API. Les allocations de licences possédaient déjà des contrôles isActive distincts.
La présente passe documente ce changement antérieur ; elle n'ajoute aucun correctif applicatif et ne confirme pas son déploiement.
## Summary
Le mécanisme décrit est confirmé dans la révision vulnérable citée, et la portée du correctif local est bornée par les tests disponibles. Correctif local non commité au début de cette rédaction ; déploiement inconnu. Les limites de couverture générale sont détaillées dans [COUVERTURE.md](../COUVERTURE.md).

Some files were not shown because too many files have changed in this diff Show More