Commit 1b339706 authored by Jordan Grossemy's avatar Jordan Grossemy
Browse files

Abandon de sonnerie après 45 s sur les appels vocaux (EV-15.9)

Premier des cinq points de durcissement relevés par la revue de sécurité de
d6950149. Un appel qui sonne sans réponse est désormais abandonné.

Ce que ça répare

Deux défauts, dont le second est le plus grave. La sonnerie du navigateur
bouclait indéfiniment : `startRingtone` répétait toutes les 2 s sans plafond. Et
surtout, un appel jamais décroché maintenait le joueur en PLAYER_BUSY, donc
INJOIGNABLE pour tout le reste de l'exercice — un appel manqué condamnait le
téléphone du personnage.

Où vit le minuteur, et pourquoi

Dans VoiceService, pas dans la passerelle. `end()` est le passage obligé de TOUS
les chemins de fin : refus, raccrochage, déconnexion de l'un ou l'autre pair.
Désarmer à cet endroit unique suffit, là où un minuteur porté par la passerelle
aurait exigé un clearTimeout dans quatre gestionnaires — dont un aurait fini par
être oublié. C'est exactement la classe de défaut corrigée juste avant sur le
contrôle d'impersonnification, et il aurait été absurde de la réintroduire en la
réparant ailleurs.

Le service ignorant Socket.IO, il ne peut pas annoncer l'abandon lui-même : la
passerelle lui fournit l'annonce via `onRingTimeout()` dans `afterInit()`. La
dépendance reste dans le bon sens, le service ne connaissant toujours pas la
passerelle qui le connaît.

45 s et non 30 : un téléphone réel renonce vers 30 s, mais en exercice le joueur
peut être en train de rédiger sa main courante, et mieux vaut lui laisser le
temps de revenir à l'onglet.

Le minuteur ne borne que la SONNERIE

Il est désarmé au décrochage. Couper une conversation en cours serait un défaut,
pas une protection : un point de situation en exercice peut légitimement durer.

Lecture d'exercice

L'abandon est distingué d'un raccrochage — `by: 'TIMEOUT'` sur l'événement,
« Sans réponse » côté animateur, « sans réponse » au journal avec la durée de
sonnerie. Ne pas décrocher est une observation d'exercice, pas un incident
technique : le RETEX doit pouvoir le lire comme tel.

Tests

7 tests à horloge simulée, dont 4 tombent quand l'armement du minuteur est
retiré. Les 3 autres sont des contre-épreuves, qui doivent passer dans les deux
cas : pas d'abandon une seconde avant l'échéance, pas de coupure d'une
conversation décrochée, pas de double écriture au journal si l'appel a déjà été
raccroché. Le minuteur est `unref()` et purgé à l'extinction du module, pour ne
retenir ni le processus ni le harnais de test.

234 tests / 23 suites, eslint et typechecks verts. Espace joueur revérifié dans
le navigateur après reconstruction.

Durcissement restant

Identifiants TURN statiques remis aux joueurs, sans objet tant que
WEBRTC_ICE_SERVERS est vide. Absence de limitation de débit sur `voice:call`.
Charges SDP et ICE relayées sans contrôle de forme. Adresses IP révélées entre
pairs, inhérent à WebRTC.

Co-Authored-By: Claude (RCA)
parent d6950149
Loading
Loading
Loading
Loading
+11 −2
Original line number Diff line number Diff line
@@ -4,6 +4,7 @@ import {
  MessageBody,
  OnGatewayConnection,
  OnGatewayDisconnect,
  OnGatewayInit,
  SubscribeMessage,
  WebSocketGateway,
  WebSocketServer,
@@ -41,7 +42,7 @@ function parseCookies(header: string | undefined): Record<string, string> {
 * L'authentification se fait a la connexion via les cookies (animateur ou joueur).
 */
@WebSocketGateway()
export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect {
export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit {
  @WebSocketServer() server!: Server;
  private readonly logger = new Logger(RealtimeGateway.name);

@@ -51,6 +52,14 @@ export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect
    private readonly voice: VoiceService,
  ) {}

  /**
   * Le service d'appels ignore Socket.IO : c'est ici qu'on lui dit comment
   * annoncer un abandon de sonnerie, une fois le serveur temps reel pret.
   */
  afterInit(): void {
    this.voice.onRingTimeout((call) => this.announceEnded(call, 'TIMEOUT'));
  }

  async handleConnection(client: Socket): Promise<void> {
    const cookies = parseCookies(client.handshake.headers.cookie);

@@ -333,7 +342,7 @@ export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect
    return { ok: false };
  }

  private announceEnded(call: CallRecord, by: 'STAFF' | 'PLAYER'): void {
  private announceEnded(call: CallRecord, by: 'STAFF' | 'PLAYER' | 'TIMEOUT'): void {
    const payload = { callId: call.callId, by };
    this.server.to(call.staffSocketId).emit(RT.voiceEnded, payload);
    this.server
+13 −0
Original line number Diff line number Diff line
@@ -82,6 +82,19 @@ export function checkCallAllowed(i: CallCheckInput): CallCheck {
  return { ok: true };
}

/**
 * Duree de sonnerie au bout de laquelle l'appel est abandonne, en secondes.
 *
 * Un telephone reel renonce vers 30 s. On accorde un peu plus ici : en exercice,
 * le joueur peut etre en train de lire un courriel ou de tenir sa main courante,
 * et il vaut mieux lui laisser le temps de revenir a l'onglet.
 *
 * Sans cette borne, deux choses restaient cassees : la sonnerie du navigateur
 * bouclait indefiniment, et l'appel jamais decroche maintenait le joueur en
 * PLAYER_BUSY — donc injoignable pour tout appel suivant.
 */
export const RING_TIMEOUT_SEC = 45;

/** Etat d'un appel, cote serveur. */
export type CallState = 'RINGING' | 'ACTIVE';

+170 −0
Original line number Diff line number Diff line
import { VoiceService } from './voice.service';
import { RING_TIMEOUT_SEC, type CallRecord } from './voice-rules';

/**
 * Abandon de sonnerie (EV-15).
 *
 * Le minuteur vit dans le service parce que `end()` est le passage oblige de
 * tous les chemins de fin : c'est le seul endroit ou desarmer une fois suffit.
 * On l'eprouve avec une horloge simulee plutot qu'en attendant 45 secondes.
 */

/** Prisma reduit a ce que le service touche reellement. */
function prismaStub() {
  const journal: { type: string; message: string }[] = [];
  const scoped = {
    exercise: {
      findFirst: async () => ({
        id: 'ex-1',
        status: 'RUNNING',
        voiceCallsEnabled: true,
        clockRunning: true,
        clockSpeed: 1,
        clockAnchorWall: new Date(),
        clockAnchorSeconds: 0,
      }),
    },
    participant: {
      findFirst: async () => ({ id: 'p-1', character: { name: 'Tom Blanchet' } }),
    },
    eventLog: {
      create: async ({ data }: { data: { type: string; message: string } }) => {
        journal.push({ type: data.type, message: data.message });
        return data;
      },
    },
  };
  return {
    journal,
    prisma: {
      forTenant: () => scoped,
      tenant: { findUnique: async () => ({ ephemeralUntil: null }) },
    } as never,
  };
}

const ENTREE = {
  tenantId: 't-1',
  exerciseId: 'ex-1',
  participantId: 'p-1',
  callerLabel: 'CERT-FR',
  staffSocketId: 'sock-1',
  playerOnline: true,
};

describe('abandon de sonnerie', () => {
  beforeEach(() => jest.useFakeTimers());
  afterEach(() => jest.useRealTimers());

  /** Laisse tourner les promesses en attente entre deux avances d'horloge. */
  const vider = async (): Promise<void> => {
    await Promise.resolve();
    await Promise.resolve();
    await Promise.resolve();
  };

  it('abandonne un appel jamais decroche, et le dit', async () => {
    const { prisma, journal } = prismaStub();
    const service = new VoiceService(prisma);
    const abandons: CallRecord[] = [];
    service.onRingTimeout((c) => abandons.push(c));

    const res = await service.place(ENTREE);
    expect(res.ok).toBe(true);

    jest.advanceTimersByTime(RING_TIMEOUT_SEC * 1000);
    await vider();

    expect(abandons).toHaveLength(1);
    expect(journal[0]?.message).toContain('sans réponse');
    expect(journal[0]?.type).toBe('VOICE_CALL');
  });

  /*
   * L'effet qui compte vraiment : sans abandon, le joueur restait en
   * PLAYER_BUSY pour un appel qui ne sonnait plus chez personne, donc
   * injoignable pour toute la suite de l'exercice.
   */
  it('libere le joueur, qui redevient joignable', async () => {
    const { prisma } = prismaStub();
    const service = new VoiceService(prisma);
    await service.place(ENTREE);

    const pendant = await service.place(ENTREE);
    expect(pendant.ok).toBe(false);
    if (!pendant.ok) expect(pendant.reason).toBe('PLAYER_BUSY');

    jest.advanceTimersByTime(RING_TIMEOUT_SEC * 1000);
    await vider();

    const apres = await service.place(ENTREE);
    expect(apres.ok).toBe(true);
  });

  it('n’abandonne pas une seconde avant l’echeance', async () => {
    const { prisma } = prismaStub();
    const service = new VoiceService(prisma);
    const abandons: CallRecord[] = [];
    service.onRingTimeout((c) => abandons.push(c));
    await service.place(ENTREE);

    jest.advanceTimersByTime((RING_TIMEOUT_SEC - 1) * 1000);
    await vider();
    expect(abandons).toHaveLength(0);
  });

  /*
   * Le minuteur ne borne que la SONNERIE. Couper une conversation en cours
   * serait un defaut, pas une protection : un point de situation peut durer.
   */
  it('ne coupe jamais une conversation decrochee', async () => {
    const { prisma, journal } = prismaStub();
    const service = new VoiceService(prisma);
    const abandons: CallRecord[] = [];
    service.onRingTimeout((c) => abandons.push(c));

    const res = await service.place(ENTREE);
    if (!res.ok) throw new Error('appel refuse');
    service.answer(res.call.callId);

    jest.advanceTimersByTime(RING_TIMEOUT_SEC * 4 * 1000);
    await vider();

    expect(abandons).toHaveLength(0);
    expect(journal).toHaveLength(0);
    expect(service.get(res.call.callId)?.state).toBe('ACTIVE');
  });

  /** Un appel raccroche avant l'echeance ne doit pas etre journalise deux fois. */
  it('ne journalise pas deux fois un appel deja termine', async () => {
    const { prisma, journal } = prismaStub();
    const service = new VoiceService(prisma);
    const res = await service.place(ENTREE);
    if (!res.ok) throw new Error('appel refuse');

    await service.end(res.call.callId, 'DECLINED');
    jest.advanceTimersByTime(RING_TIMEOUT_SEC * 2 * 1000);
    await vider();

    expect(journal).toHaveLength(1);
    expect(journal[0]?.message).toContain('refusé');
  });

  it('n’exige pas qu’une annonce ait ete enregistree', async () => {
    const { prisma, journal } = prismaStub();
    const service = new VoiceService(prisma); // aucun onRingTimeout
    await service.place(ENTREE);
    jest.advanceTimersByTime(RING_TIMEOUT_SEC * 1000);
    await vider();
    expect(journal).toHaveLength(1);
  });

  it('n’oublie aucun minuteur a l’extinction du module', async () => {
    const { prisma } = prismaStub();
    const service = new VoiceService(prisma);
    await service.place(ENTREE);
    expect(jest.getTimerCount()).toBeGreaterThan(0);
    service.onModuleDestroy();
    expect(jest.getTimerCount()).toBe(0);
  });
});
+55 −2
Original line number Diff line number Diff line
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, type OnModuleDestroy } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import { computeElapsedSeconds } from './clock';
@@ -6,6 +6,7 @@ import {
  callLogMessage,
  checkCallAllowed,
  ringDurationSec,
  RING_TIMEOUT_SEC,
  talkDurationSec,
  type CallEnd,
  type CallRecord,
@@ -25,13 +26,23 @@ import {
 * `SchedulerService`. A plusieurs replicas il faudrait un registre partage.
 */
@Injectable()
export class VoiceService {
export class VoiceService implements OnModuleDestroy {
  private readonly logger = new Logger(VoiceService.name);

  /** Appels en cours, par identifiant d'appel. */
  private readonly calls = new Map<string, CallRecord>();
  /** Index inverse : un joueur ne peut avoir qu'un appel a la fois. */
  private readonly byParticipant = new Map<string, string>();
  /** Minuteurs d'abandon de sonnerie, par identifiant d'appel. */
  private readonly ringTimers = new Map<string, NodeJS.Timeout>();
  /**
   * Annonce de l'abandon, fournie par la passerelle.
   *
   * Ce service ignore Socket.IO — c'est ce qui lui evite de dependre de la
   * passerelle, laquelle depend de lui. L'expiration devant pourtant etre dite
   * aux deux pairs, la passerelle s'enregistre ici une fois pour toutes.
   */
  private announceTimeout?: (call: CallRecord) => void;

  constructor(private readonly prisma: PrismaService) {}

@@ -120,9 +131,42 @@ export class VoiceService {
    };
    this.calls.set(call.callId, call);
    this.byParticipant.set(call.participantId, call.callId);
    this.armRingTimeout(call.callId);
    return { ok: true, call };
  }

  /** La passerelle declare comment annoncer un abandon de sonnerie. */
  onRingTimeout(handler: (call: CallRecord) => void): void {
    this.announceTimeout = handler;
  }

  /** Arme l'abandon de sonnerie. `unref` : ce minuteur ne retient pas le processus. */
  private armRingTimeout(callId: string): void {
    const timer = setTimeout(() => {
      void (async () => {
        // `end` verifie l'existence : un appel deja termine ne produit rien.
        const call = await this.end(callId, 'MISSED');
        if (call) this.announceTimeout?.(call);
      })();
    }, RING_TIMEOUT_SEC * 1000);
    timer.unref();
    this.ringTimers.set(callId, timer);
  }

  private clearRingTimeout(callId: string): void {
    const timer = this.ringTimers.get(callId);
    if (timer) {
      clearTimeout(timer);
      this.ringTimers.delete(callId);
    }
  }

  /** Arret propre : aucun minuteur ne survit a l'extinction du module. */
  onModuleDestroy(): void {
    for (const timer of this.ringTimers.values()) clearTimeout(timer);
    this.ringTimers.clear();
  }

  get(callId: string): CallRecord | undefined {
    return this.calls.get(callId);
  }
@@ -143,6 +187,12 @@ export class VoiceService {
    if (!call || call.state !== 'RINGING') return undefined;
    call.state = 'ACTIVE';
    call.answeredAtMs = Date.now();
    /*
     * Le minuteur ne borne que la SONNERIE. Une fois decroche, la conversation
     * n'est pas limitee : un point de situation en exercice peut durer, et
     * couper la parole serait un defaut, pas une protection.
     */
    this.clearRingTimeout(callId);
    return call;
  }

@@ -156,6 +206,9 @@ export class VoiceService {
  async end(callId: string, end: CallEnd): Promise<CallRecord | undefined> {
    const call = this.calls.get(callId);
    if (!call) return undefined;
    // `end` est le passage oblige de TOUS les chemins de fin : desarmer ici
    // suffit, plutot qu'a chaque appelant.
    this.clearRingTimeout(callId);
    this.calls.delete(callId);
    if (this.byParticipant.get(call.participantId) === callId) {
      this.byParticipant.delete(call.participantId);
+6 −2
Original line number Diff line number Diff line
@@ -145,9 +145,13 @@ export function StaffPhone({
      teardown('Appel refusé par le joueur.');
    };

    const onEnded = (p: { callId: string }): void => {
    const onEnded = (p: { callId: string; by?: string }): void => {
      if (callIdRef.current !== p.callId) return;
      teardown('Appel terminé.');
      // Un abandon de sonnerie n'est pas un appel « terminé » : c'est une
      // absence de réponse, et c'est une observation d'exercice.
      teardown(
        p.by === 'TIMEOUT' ? 'Sans réponse : le joueur n’a pas décroché.' : 'Appel terminé.',
      );
    };

    const onIce = (p: { callId: string; candidate: RTCIceCandidateInit }): void => {