Commit d6950149 authored by Jordan Grossemy's avatar Jordan Grossemy
Browse files

Sécurité : autorisation de la signalisation des appels vocaux (EV-15)

Revue de sécurité de la signalisation introduite par 7c781b17. Trois constats,
tous corrigés. Aucun changement de comportement pour un usage légitime.

1. HIGH — un OBSERVER pouvait passer un appel

La cause était en amont des gestionnaires : `handleConnection` retenait
`{ kind: 'staff', tenantId }` et JETAIT le rôle du jeton. Aucun contrôle de rôle
n'était donc possible côté temps réel, alors que toutes les routes d'animation
portent @Roles('TENANT_ADMIN','DESIGNER','ANIMATOR'). L'observateur est un profil
qui regarde sans agir, et passer un appel EST l'envoi d'un stimulus : le refus
était donc une régression de doctrine, pas un durcissement.

Le rôle est conservé à la connexion et la règle vit dans le module pur
(`canPlaceCall`), en LISTE BLANCHE — un rôle ajouté au produit demain n'héritera
pas de ce droit par simple absence de refus. Aucune exception pour
INSTANCE_ADMIN, comme le garde de rôles HTTP qui n'en fait aucune.

`voice:config` est fermé au même titre : ces serveurs portent potentiellement des
identifiants TURN, qui n'ont rien à faire chez qui ne peut pas appeler.

Le refus rend INSUFFICIENT_ROLE, distinct de FORBIDDEN : le second signifie
« pas animateur du tout », le premier se corrige par une habilitation, et
l'animateur doit pouvoir lire la différence.

2. MEDIUM — une session « voir comme » pouvait refuser ou raccrocher

Le contrôle d'impersonnification n'existait que sur `voice:answer`. Un animateur
observant l'espace d'un joueur pouvait donc refuser un appel qui lui était
destiné — et le journal l'inscrivait AU NOM DU PERSONNAGE, falsifiant la donnée
d'observation que l'exercice sert précisément à recueillir.

Le contrôle descend dans `playerCtx()`. Je n'ajoute pas trois contrôles : je
déplace le seul existant au point d'entrée unique. C'était l'oubli d'un appel qui
ouvrait la brèche, donc la classe de défaut disparaît et un gestionnaire futur
sera couvert par construction.

Asymétrie assumée, documentée dans le code : raccrocher reste ouvert à
`staffCtx` + propriété de l'appel, sans revérifier le rôle. Posséder l'appel
prouve que le droit a été vérifié à l'ouverture ; l'exiger à nouveau interdirait
de raccrocher à qui l'aurait perdu entre-temps, laissant un appel ouvert —
l'inverse du but.

3. LOW — un observateur empêchait la clôture d'un appel

Même racine que le précédent : une session « voir comme » partage la salle du
participant sans être un de ses onglets. `handleDisconnect` la comptait comme tel,
donc si le vrai joueur fermait son onglet pendant qu'un animateur regardait,
l'appel restait ouvert et le joueur bloqué en PLAYER_BUSY. Les sessions
impersonnifiées sont exclues du décompte.

Tests

18 tests de passerelle et 29 du module pur. Ils ont été éprouvés en RETIRANT les
correctifs : 8 des 14 premiers tombent pour les constats 1 et 2, et 1 des 4
ajoutés pour le constat 3 — les trois autres étant des contre-épreuves, qui
doivent passer dans les deux cas pour garantir qu'on n'a pas corrigé trop large.
Couper un appel alors que le joueur garde un onglet ouvert serait une régression
pire que le défaut réparé.

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

Confirmé sain par la revue, sans modification

Cloisonnement (forTenant puis participant DANS l'exercice) ; callId en
randomUUID non devinable ET non porteur de droit, chaque gestionnaire revérifiant
la propriété ; pas d'oracle d'énumération, les motifs de refus étant
volontairement confondus ; CSWSH bloqué par sameSite=lax ; registre mémoire borné
par le nombre de sockets joueurs connectées et non par le nombre de requêtes ;
aucune dépendance ajoutée ; aucun secret ; pas de vecteur XSS.

Durcissement restant, non traité et à arbitrer

Identifiants TURN statiques remis aux joueurs — porteurs de lien anonymes — par
`voice:incoming` : sans objet tant que WEBRTC_ICE_SERVERS est vide, à traiter
avant d'en configurer un. Absence de limitation de débit sur `voice:call`. Absence
de délai d'expiration de sonnerie. Charges SDP et ICE relayées sans contrôle de
forme. Adresses IP révélées entre pairs par les candidats ICE, propriété
inhérente à WebRTC, contournable par iceTransportPolicy 'relay' au prix de rendre
le TURN obligatoire.

Co-Authored-By: Claude (RCA)
parent 7c781b17
Loading
Loading
Loading
Loading
+242 −0
Original line number Diff line number Diff line
import { RealtimeGateway } from './realtime.gateway';
import type { CallRecord } from './voice-rules';

/**
 * Autorisation de la signalisation des appels (EV-15).
 *
 * Ces deux controles ne sont pas dans le module pur : ils dependent du contexte
 * porte par la socket. Ils sont pourtant les deux constats de la revue de
 * securite, donc ils meritent d'etre epingles ici.
 */

const APPEL: CallRecord = {
  callId: 'appel-1',
  exerciseId: 'ex-1',
  tenantId: 't-1',
  participantId: 'p-1',
  callerLabel: 'CERT-FR',
  staffSocketId: 'sock-animateur',
  state: 'RINGING',
  ringingAtMs: 1_000,
};

/** Socket minimale : seul `data.ctx` compte pour l'autorisation. */
function socket(id: string, ctx: unknown) {
  return { id, data: { ctx } } as never;
}

function build(
  overrides: Partial<Record<string, unknown>> = {},
  restants: { id: string; data: { ctx: unknown } }[] = [{ id: 'sock-joueur', data: { ctx: {} } }],
) {
  const emitted: { room: string; event: string }[] = [];
  const voice = {
    iceServers: () => [{ urls: 'turn:exemple' }],
    get: () => APPEL,
    answer: () => APPEL,
    end: async () => APPEL,
    place: async () => ({ ok: true, call: APPEL }),
    callsOfStaffSocket: () => [],
    callOfParticipant: () => undefined,
    ...overrides,
  };
  const gateway = new RealtimeGateway({} as never, {} as never, voice as never);
  gateway.server = {
    to: (room: string) => ({
      emit: (event: string) => {
        emitted.push({ room, event });
      },
    }),
    in: () => ({ fetchSockets: async () => restants }),
  } as never;
  return { gateway, emitted, voice };
}

const OFFRE = { type: 'offer', sdp: 'v=0' };

describe('voice:call — role de l’appelant', () => {
  it.each(['TENANT_ADMIN', 'DESIGNER', 'ANIMATOR'])('accepte %s', async (role) => {
    const { gateway } = build();
    const res = await gateway.onVoiceCall(socket('s', { kind: 'staff', tenantId: 't-1', role }), {
      exerciseId: 'ex-1',
      participantId: 'p-1',
      callerLabel: 'CERT-FR',
      sdp: OFFRE,
    });
    expect(res.ok).toBe(true);
  });

  /*
   * LE constat de la revue : `OBSERVER` est exclu de toutes les routes
   * d'animation, mais la passerelle jetait le role a la connexion et ne pouvait
   * donc pas le voir. Un observateur pouvait envoyer un stimulus telephonique.
   */
  it('refuse OBSERVER avec un motif lisible', async () => {
    const { gateway } = build();
    const res = await gateway.onVoiceCall(
      socket('s', { kind: 'staff', tenantId: 't-1', role: 'OBSERVER' }),
      { exerciseId: 'ex-1', participantId: 'p-1', callerLabel: 'X', sdp: OFFRE },
    );
    expect(res).toEqual({ ok: false, reason: 'INSUFFICIENT_ROLE' });
  });

  it('refuse une socket sans role du tout', async () => {
    const { gateway } = build();
    const res = await gateway.onVoiceCall(socket('s', { kind: 'staff', tenantId: 't-1' }), {
      exerciseId: 'ex-1',
      participantId: 'p-1',
      callerLabel: 'X',
      sdp: OFFRE,
    });
    expect(res).toEqual({ ok: false, reason: 'INSUFFICIENT_ROLE' });
  });

  it('refuse un joueur qui tenterait de composer', async () => {
    const { gateway } = build();
    const res = await gateway.onVoiceCall(
      socket('s', { kind: 'player', pid: 'p-1', eid: 'ex-1' }),
      { exerciseId: 'ex-1', participantId: 'p-2', callerLabel: 'X', sdp: OFFRE },
    );
    expect(res).toEqual({ ok: false, reason: 'FORBIDDEN' });
  });

  it('ne livre aucun serveur ICE a un observateur', () => {
    const { gateway } = build();
    expect(
      gateway.voiceConfig(socket('s', { kind: 'staff', tenantId: 't-1', role: 'OBSERVER' })),
    ).toEqual({ iceServers: [] });
    expect(
      gateway.voiceConfig(socket('s', { kind: 'staff', tenantId: 't-1', role: 'ANIMATOR' }))
        .iceServers,
    ).toHaveLength(1);
  });
});

describe('session « voir comme » — aucune action sur un appel', () => {
  const IMPERSONNE = { kind: 'player', pid: 'p-1', eid: 'ex-1', imp: 'user-animateur' };
  const VRAI_JOUEUR = { kind: 'player', pid: 'p-1', eid: 'ex-1' };

  it('refuse de decrocher', async () => {
    const { gateway } = build();
    const res = await gateway.onVoiceAnswer(socket('s', IMPERSONNE), {
      callId: 'appel-1',
      sdp: OFFRE,
    });
    expect(res.ok).toBe(false);
  });

  /*
   * Le trou : le controle d'impersonnification n'existait que pour « decrocher ».
   * Refuser inscrivait au journal, AU NOM DU PERSONNAGE, un refus que le joueur
   * n'avait pas fait — une falsification de la donnee d'observation.
   */
  it('refuse de refuser l’appel', async () => {
    const { gateway } = build();
    expect(await gateway.onVoiceDecline(socket('s', IMPERSONNE), { callId: 'appel-1' })).toEqual({
      ok: false,
    });
  });

  it('refuse de raccrocher', async () => {
    const { gateway } = build();
    expect(await gateway.onVoiceHangup(socket('s', IMPERSONNE), { callId: 'appel-1' })).toEqual({
      ok: false,
    });
  });

  it('refuse de relayer un candidat ICE', () => {
    const { gateway } = build();
    expect(
      gateway.onVoiceIce(socket('s', IMPERSONNE), {
        callId: 'appel-1',
        candidate: { candidate: 'x' },
      }),
    ).toEqual({ ok: false });
  });

  it('n’ecrit RIEN au journal pour une session « voir comme »', async () => {
    let fins = 0;
    const { gateway } = build({ end: async () => ((fins += 1), APPEL) });
    await gateway.onVoiceDecline(socket('s', IMPERSONNE), { callId: 'appel-1' });
    await gateway.onVoiceHangup(socket('s', IMPERSONNE), { callId: 'appel-1' });
    expect(fins).toBe(0);
  });

  /** Contre-epreuve : le vrai joueur, lui, agit normalement. */
  it('laisse agir le vrai joueur', async () => {
    const { gateway } = build();
    expect(await gateway.onVoiceDecline(socket('s', VRAI_JOUEUR), { callId: 'appel-1' })).toEqual({
      ok: true,
    });
    expect(
      gateway.onVoiceIce(socket('s', VRAI_JOUEUR), {
        callId: 'appel-1',
        candidate: { candidate: 'x' },
      }),
    ).toEqual({ ok: true });
  });

  /*
   * Un appel destine a un AUTRE joueur reste inatteignable, meme pour un vrai
   * joueur : connaitre un callId ne donne aucun droit dessus.
   */
  it('refuse un joueur qui n’est pas la cible de l’appel', async () => {
    const { gateway } = build();
    const autre = { kind: 'player', pid: 'p-999', eid: 'ex-1' };
    expect(await gateway.onVoiceDecline(socket('s', autre), { callId: 'appel-1' })).toEqual({
      ok: false,
    });
    expect(
      await gateway.onVoiceAnswer(socket('s', autre), { callId: 'appel-1', sdp: OFFRE }),
    ).toEqual({ ok: false, reason: 'NOT_FOUND' });
  });
});

describe('handleDisconnect — décompte des onglets du joueur', () => {
  const JOUEUR = { kind: 'player', pid: 'p-1', eid: 'ex-1' };
  const OBSERVATEUR = { kind: 'player', pid: 'p-1', eid: 'ex-1', imp: 'user-animateur' };
  const ACTIF: CallRecord = { ...APPEL, state: 'ACTIVE', answeredAtMs: 2_000 };

  /** Compte les clôtures d'appel provoquées par la déconnexion. */
  function harnais(restants: { id: string; data: { ctx: unknown } }[]) {
    const fins: string[] = [];
    const { gateway } = build(
      {
        callOfParticipant: () => ACTIF,
        end: async (_id: string, fin: string) => (fins.push(fin), ACTIF),
      },
      restants,
    );
    return { gateway, fins };
  }

  /*
   * LE constat : la socket d'un observateur « voir comme » partage la salle du
   * participant. La compter comme un onglet du joueur laissait l'appel ouvert et
   * le joueur bloqué en PLAYER_BUSY parce qu'un animateur regardait.
   */
  it('clôt l’appel quand seul un observateur reste dans la salle', async () => {
    const { gateway, fins } = harnais([{ id: 'sock-observateur', data: { ctx: OBSERVATEUR } }]);
    await gateway.handleDisconnect(socket('sock-joueur', JOUEUR));
    expect(fins).toEqual(['ANSWERED']);
  });

  it('ne clôt pas l’appel si le joueur garde un autre onglet', async () => {
    const { gateway, fins } = harnais([{ id: 'sock-onglet-2', data: { ctx: JOUEUR } }]);
    await gateway.handleDisconnect(socket('sock-joueur', JOUEUR));
    expect(fins).toEqual([]);
  });

  it('clôt l’appel quand le dernier onglet du joueur part', async () => {
    const { gateway, fins } = harnais([]);
    await gateway.handleDisconnect(socket('sock-joueur', JOUEUR));
    expect(fins).toEqual(['ANSWERED']);
  });

  /* Le départ de l'observateur, lui, ne touche à rien : il ne possède aucun appel. */
  it('ne touche à rien quand c’est l’observateur qui part', async () => {
    const { gateway, fins } = harnais([{ id: 'sock-joueur', data: { ctx: JOUEUR } }]);
    await gateway.handleDisconnect(socket('sock-observateur', OBSERVATEUR));
    expect(fins).toEqual([]);
  });
});
+81 −21
Original line number Diff line number Diff line
@@ -21,7 +21,7 @@ import {
} from '../auth/auth.types';
import { RT, exerciseRoom, participantRoom } from './engine.types';
import { VoiceService } from './voice.service';
import type { CallRecord } from './voice-rules';
import { canPlaceCall, type CallRecord } from './voice-rules';

function parseCookies(header: string | undefined): Record<string, string> {
  const out: Record<string, string> = {};
@@ -98,7 +98,12 @@ export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect
    if (sessionToken) {
      try {
        const u = await this.jwt.verifyAsync<JwtPayload>(sessionToken);
        client.data.ctx = { kind: 'staff', tenantId: u.tid };
        /*
         * Le role est conserve, et non plus jete : sans lui, la passerelle ne
         * pouvait distinguer un animateur d'un observateur, alors que toutes
         * les routes d'animation refusent le second.
         */
        client.data.ctx = { kind: 'staff', tenantId: u.tid, role: u.role };
        return;
      } catch {
        /* ignore */
@@ -133,23 +138,53 @@ export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect
  // navigateurs. Ces gestionnaires ne relaient que la signalisation, et chacun
  // verifie qui a le droit de parler a qui.

  private staffCtx(client: Socket): { tenantId: string } | null {
    const ctx = client.data.ctx as { kind?: string; tenantId?: string } | undefined;
    return ctx?.kind === 'staff' && ctx.tenantId ? { tenantId: ctx.tenantId } : null;
  private staffCtx(client: Socket): { tenantId: string; role?: string } | null {
    const ctx = client.data.ctx as { kind?: string; tenantId?: string; role?: string } | undefined;
    return ctx?.kind === 'staff' && ctx.tenantId
      ? { tenantId: ctx.tenantId, role: ctx.role }
      : null;
  }

  private playerCtx(client: Socket): { pid: string; eid: string; imp?: string } | null {
  /**
   * Animateur AYANT LE DROIT d'agir sur un appel.
   *
   * Distinct de `staffCtx` : appartenir a l'organisation suffit pour ECOUTER une
   * salle d'exercice, pas pour passer un appel — qui est l'envoi d'un stimulus,
   * refuse a `OBSERVER` sur toutes les autres routes d'animation.
   */
  private callerCtx(client: Socket): { tenantId: string } | null {
    const staff = this.staffCtx(client);
    if (!staff || !canPlaceCall(staff.role)) return null;
    return { tenantId: staff.tenantId };
  }

  /**
   * Joueur AGISSANT en son propre nom.
   *
   * Rend `null` pour une session « voir comme » : le garde HTTP la tient en
   * lecture seule, et decrocher, refuser ou raccrocher ne sont pas des lectures.
   * Un animateur qui observe l'espace d'un joueur ne doit pas pouvoir agir a sa
   * place — il inscrirait au journal, AU NOM DU PERSONNAGE, un refus que le
   * joueur n'a pas fait.
   *
   * Le controle est porte ICI, et non dans chaque gestionnaire : c'etait
   * l'oubli d'un seul appel qui ouvrait la breche.
   */
  private playerCtx(client: Socket): { pid: string; eid: string } | null {
    const ctx = client.data.ctx as
      | { kind?: string; pid?: string; eid?: string; imp?: string }
      | undefined;
    if (ctx?.kind !== 'player' || !ctx.pid || !ctx.eid) return null;
    return { pid: ctx.pid, eid: ctx.eid, imp: ctx.imp };
    if (ctx.imp) return null;
    return { pid: ctx.pid, eid: ctx.eid };
  }

  /** Serveurs ICE a utiliser. Demande par l'animateur AVANT de composer. */
  @SubscribeMessage('voice:config')
  voiceConfig(@ConnectedSocket() client: Socket): { iceServers: unknown[] } {
    if (!this.staffCtx(client)) return { iceServers: [] };
    // Meme condition que pour composer : ces serveurs portent potentiellement
    // des identifiants TURN, qui n'ont rien a faire chez qui ne peut pas appeler.
    if (!this.callerCtx(client)) return { iceServers: [] };
    return { iceServers: this.voice.iceServers() };
  }

@@ -163,14 +198,24 @@ export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect
    @MessageBody()
    body: { exerciseId?: string; participantId?: string; callerLabel?: string; sdp?: unknown },
  ): Promise<{ ok: boolean; callId?: string; reason?: string }> {
    const staff = this.staffCtx(client);
    if (!staff) return { ok: false, reason: 'FORBIDDEN' };
    const staff = this.callerCtx(client);
    if (!staff) {
      // Distingue « pas animateur du tout » de « role insuffisant » : le second
      // se corrige par une habilitation, et l'animateur doit pouvoir le lire.
      return {
        ok: false,
        reason: this.staffCtx(client) ? 'INSUFFICIENT_ROLE' : 'FORBIDDEN',
      };
    }
    if (!body?.exerciseId || !body.participantId || !body.sdp) {
      return { ok: false, reason: 'BAD_REQUEST' };
    }

    // Etiquette bornee : elle s'affiche telle quelle sur le telephone du joueur.
    const callerLabel = String(body.callerLabel ?? '').trim().slice(0, 60) || 'Appel masqué';
    const callerLabel =
      String(body.callerLabel ?? '')
        .trim()
        .slice(0, 60) || 'Appel masqué';

    const sockets = await this.server
      .in(participantRoom(body.exerciseId, body.participantId))
@@ -202,13 +247,8 @@ export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect
    @MessageBody() body: { callId?: string; sdp?: unknown },
  ): Promise<{ ok: boolean; reason?: string }> {
    const player = this.playerCtx(client);
    // `playerCtx` ecarte deja les sessions « voir comme » : voir sa definition.
    if (!player || !body?.callId || !body.sdp) return { ok: false, reason: 'FORBIDDEN' };
    /*
     * Une session « voir comme » est STRICTEMENT en lecture cote HTTP.
     * Decrocher n'est pas une lecture : un animateur qui observe l'espace d'un
     * joueur ne doit pas pouvoir prendre l'appel a sa place.
     */
    if (player.imp) return { ok: false, reason: 'READ_ONLY' };

    const call = this.voice.get(body.callId);
    if (!call || call.participantId !== player.pid) return { ok: false, reason: 'NOT_FOUND' };
@@ -216,7 +256,9 @@ export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect
    const answered = this.voice.answer(body.callId);
    if (!answered) return { ok: false, reason: 'NOT_RINGING' };

    this.server.to(call.staffSocketId).emit(RT.voiceAnswered, { callId: call.callId, sdp: body.sdp });
    this.server
      .to(call.staffSocketId)
      .emit(RT.voiceAnswered, { callId: call.callId, sdp: body.sdp });
    return { ok: true };
  }

@@ -246,6 +288,12 @@ export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect
    const call = this.voice.get(body.callId);
    if (!call) return { ok: false };

    /*
     * Ici `staffCtx` suffit, et non `callerCtx` : posseder l'appel prouve deja
     * que le droit d'appeler a ete verifie a l'ouverture. Exiger le role a
     * nouveau interdirait de RACCROCHER a qui l'aurait perdu entre-temps, ce
     * qui laisserait un appel ouvert — l'inverse du but.
     */
    const staff = this.staffCtx(client);
    const player = this.playerCtx(client);
    const parAnimation = staff !== null && call.staffSocketId === client.id;
@@ -288,7 +336,9 @@ export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect
  private announceEnded(call: CallRecord, by: 'STAFF' | 'PLAYER'): void {
    const payload = { callId: call.callId, by };
    this.server.to(call.staffSocketId).emit(RT.voiceEnded, payload);
    this.server.to(participantRoom(call.exerciseId, call.participantId)).emit(RT.voiceEnded, payload);
    this.server
      .to(participantRoom(call.exerciseId, call.participantId))
      .emit(RT.voiceEnded, payload);
  }

  /**
@@ -304,9 +354,19 @@ export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect

    const player = this.playerCtx(client);
    if (!player) return;
    // Le joueur peut avoir plusieurs onglets : on ne coupe que si le dernier part.
    /*
     * Le joueur peut avoir plusieurs onglets : on ne coupe que si le dernier
     * part. Une session « voir comme » partage la salle du participant mais
     * n'est PAS un de ses onglets : la compter maintenait l'appel ouvert — et
     * le joueur bloque en PLAYER_BUSY — parce qu'un animateur regardait.
     */
    const restants = await this.server.in(participantRoom(player.eid, player.pid)).fetchSockets();
    if (restants.some((s) => s.id !== client.id)) return;
    const autresOnglets = restants.filter((s) => {
      if (s.id === client.id) return false;
      const ctx = s.data.ctx as { imp?: string } | undefined;
      return !ctx?.imp;
    });
    if (autresOnglets.length > 0) return;

    const call = this.voice.callOfParticipant(player.pid);
    if (!call) return;
+38 −0
Original line number Diff line number Diff line
import {
  callLogMessage,
  canPlaceCall,
  CALL_ROLES,
  checkCallAllowed,
  ringDurationSec,
  talkDurationSec,
@@ -160,3 +162,39 @@ describe('callLogMessage', () => {
    expect(callLogMessage('ANSWERED', 'X', 'Y', 10, 2)).toContain('décroché');
  });
});

describe('canPlaceCall', () => {
  it.each(['TENANT_ADMIN', 'DESIGNER', 'ANIMATOR'])('autorise le role %s', (role) => {
    expect(canPlaceCall(role)).toBe(true);
  });

  /*
   * Le constat de la revue de securite : un observateur pouvait passer un appel,
   * alors que toutes les routes d'animation le lui refusent. Passer un appel est
   * l'envoi d'un stimulus.
   */
  it('refuse OBSERVER — profil qui regarde sans agir', () => {
    expect(canPlaceCall('OBSERVER')).toBe(false);
  });

  /*
   * Pas d'exception pour l'administrateur d'instance : le garde de roles HTTP
   * n'en fait aucune, il agit apres bascule dans l'organisation.
   */
  it('refuse INSTANCE_ADMIN, comme le garde de roles HTTP', () => {
    expect(canPlaceCall('INSTANCE_ADMIN')).toBe(false);
  });

  it.each([undefined, null, '', 'ROLE_INVENTE'])('refuse une valeur non listee (%s)', (role) => {
    expect(canPlaceCall(role as string | undefined)).toBe(false);
  });

  /*
   * Liste blanche et non liste noire : un role ajoute au produit demain ne doit
   * pas heriter du droit d'appeler par simple absence de refus.
   */
  it('n’accorde le droit qu’aux roles explicitement listes', () => {
    expect(CALL_ROLES).toEqual(['TENANT_ADMIN', 'DESIGNER', 'ANIMATOR']);
    expect(CALL_ROLES).not.toContain('OBSERVER');
  });
});
+21 −0
Original line number Diff line number Diff line
@@ -24,6 +24,27 @@ export type CallRefusal =
  /** Ce joueur n'a aucun onglet connecte. */
  | 'PLAYER_OFFLINE';

/**
 * Roles autorises a passer un appel.
 *
 * Exactement la liste des routes d'animation (injects, horloge, phases) : passer
 * un appel EST l'envoi d'un stimulus. `OBSERVER` en est donc exclu — c'est un
 * profil qui regarde sans agir — et `INSTANCE_ADMIN` aussi, comme partout
 * ailleurs : un administrateur d'instance agit apres bascule dans
 * l'organisation, avec le role que cette bascule lui donne.
 */
export const CALL_ROLES: readonly string[] = ['TENANT_ADMIN', 'DESIGNER', 'ANIMATOR'];

/**
 * Le role permet-il de passer un appel ?
 *
 * Liste blanche : un role inconnu, absent ou vide est refuse. Un nouveau role
 * ajoute au produit n'obtient donc pas ce droit par inadvertance.
 */
export function canPlaceCall(role: string | undefined | null): boolean {
  return role != null && CALL_ROLES.includes(role);
}

export interface CallCheckInput {
  /** `Exercise.voiceCallsEnabled`. */
  voiceCallsEnabled: boolean;
+2 −0
Original line number Diff line number Diff line
@@ -23,6 +23,8 @@ const REFUS: Record<string, string> = {
  EXERCISE_NOT_LIVE: 'L’exercice doit être en cours pour passer un appel.',
  PLAYER_BUSY: 'Ce joueur est déjà en ligne.',
  PLAYER_OFFLINE: 'Ce joueur n’a aucun onglet ouvert : il ne verrait rien sonner.',
  INSUFFICIENT_ROLE:
    'Votre rôle ne permet pas de passer un appel : c’est un stimulus, réservé à l’animation (administrateur, concepteur, animateur).',
  FORBIDDEN: 'Appel refusé.',
  BAD_REQUEST: 'Appel incomplet.',
};