Paillasse/portail/tests/Feature/VisioTest.php
Adam Belkacemi ec4eae3744 ajoute la visio de classe : Jitsi auto-hébergé, jetons du portail, prof seul modérateur
Jitsi (docker-jitsi-meet, version figée) derrière Nginx sur meet.syllabuslab.fr,
accès par jeton JWT signé par le portail, modération du micro, mains levées
synchronisées avec l'application. Pare-feu : UDP 10000 et pont interne
br-visio (accords d'Adam). Jicofo n'authentifie plus lui-même : il donnait
le rôle de propriétaire aux élèves. Capacité ramenée à 20 VM.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:35:35 +02:00

139 lines
6.9 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use App\Models\Classe;
use App\Models\Eleve;
use App\Models\Lab;
use App\Models\Seance;
use App\Models\User;
use App\Services\Visio\JetonVisio;
use Illuminate\Support\Facades\Process;
beforeEach(function () {
config(['visio.domaine' => 'meet.test', 'visio.jwt.secret' => str_repeat('s', 64)]);
});
function seanceAvecClasse(): Seance
{
$classe = Classe::factory()->create();
return Seance::create(['classe_id' => $classe->id, 'lab_id' => Lab::factory()->create()->id, 'statut' => Seance::SALLE_ATTENTE, 'demarree_a' => now()]);
}
function prof($test, User $prof)
{
return $test->withSession(['jeton_session_web' => $prof->demarreNouvelleSession()])->actingAs($prof, 'web');
}
function eleve($test, Eleve $eleve)
{
return $test->withSession(['jeton_session_eleve' => $eleve->demarreNouvelleSession()])->actingAs($eleve, 'eleve');
}
/** Décode un JWT HS256 en vérifiant sa signature, comme le fait Prosody. */
function decoderJeton(string $jeton): array
{
[$entete, $corps, $signature] = explode('.', $jeton);
$attendue = rtrim(strtr(base64_encode(hash_hmac('sha256', "{$entete}.{$corps}", str_repeat('s', 64), true)), '+/', '-_'), '=');
expect(hash_equals($attendue, $signature))->toBeTrue();
return json_decode(base64_decode(strtr($corps, '-_', '+/')), true);
}
test('le jeton est signé, limité à la salle, et seul le prof est modérateur', function () {
$seance = seanceAvecClasse();
$seance->update(['visio_ouverte_a' => now(), 'visio_salle' => 'syllabus-1-abc']);
$prof = decoderJeton(app(JetonVisio::class)->pour($seance, 'prof-1', 'M. Prof', true));
$eleve = decoderJeton(app(JetonVisio::class)->pour($seance, 'eleve-1', 'Léa M.', false));
expect($prof['room'])->toBe('syllabus-1-abc')
->and($prof['iss'])->toBe('syllabus')->and($prof['aud'])->toBe('syllabus-visio')
->and($prof['context']['user']['moderator'])->toBeTrue()
->and($eleve['context']['user']['moderator'])->toBeFalse()
->and($eleve['context']['user']['name'])->toBe('Léa M.')
->and($eleve['exp'])->toBeGreaterThan(time());
});
test('le prof ouvre la visio ; un autre prof ne peut ni l’ouvrir ni la voir', function () {
$seance = seanceAvecClasse();
prof($this, User::factory()->create())->post(route('seances.visio.ouvrir', $seance))->assertForbidden();
prof($this, $seance->classe->prof)->post(route('seances.visio.ouvrir', $seance))->assertRedirect(route('seances.visio.prof', $seance));
$seance->refresh();
expect($seance->visioOuverte())->toBeTrue()->and($seance->visio_salle)->toMatch('/^syllabus-\d+-[a-z0-9]{16}$/');
$page = prof($this, $seance->classe->prof)->get(route('seances.visio.prof', $seance))->assertOk()->assertSee('https://meet.test/external_api.js', false);
preg_match('/jwt: "([^"]+)"/', $page->getContent(), $m);
expect(decoderJeton($m[1])['context']['user']['moderator'])->toBeTrue();
expect($page->headers->get('Content-Security-Policy'))->toContain('frame-src \'self\' https://meet.test');
});
test('un élève de la classe attend, puis entre sans être modérateur, avec un nom non modifiable ; un élève d’une autre classe est refusé', function () {
$seance = seanceAvecClasse();
$lea = Eleve::factory()->create(['classe_id' => $seance->classe_id, 'prenom' => 'Léa', 'nom' => 'Martin']);
eleve($this, $lea)->get(route('seances.visio.eleve', $seance))->assertOk()->assertSee('pas encore ouvert la visio', false)->assertDontSee('jwt:', false);
$seance->update(['visio_ouverte_a' => now(), 'visio_salle' => 'syllabus-x']);
$page = eleve($this, $lea)->get(route('seances.visio.eleve', $seance))->assertOk()->assertSee('readOnlyName: true', false);
preg_match('/jwt: "([^"]+)"/', $page->getContent(), $m);
$jeton = decoderJeton($m[1]);
expect($jeton['context']['user']['moderator'])->toBeFalse()->and($jeton['context']['user']['name'])->toBe('Léa M.');
$intrus = Eleve::factory()->create();
eleve($this, $intrus)->get(route('seances.visio.eleve', $seance))->assertForbidden();
});
test('les mains levées arrivent chez le prof dans l’ordre ; donner la parole baisse la main', function () {
$seance = seanceAvecClasse();
$seance->update(['visio_ouverte_a' => now(), 'visio_salle' => 'syllabus-x']);
$a = Eleve::factory()->create(['classe_id' => $seance->classe_id, 'prenom' => 'Yanis', 'nom' => 'B']);
$b = Eleve::factory()->create(['classe_id' => $seance->classe_id, 'prenom' => 'Adam', 'nom' => 'D']);
eleve($this, $a)->postJson(route('seances.visio.eleve.participant', $seance), ['participant_id' => 'abc123']);
eleve($this, $a)->postJson(route('seances.visio.eleve.main', $seance), ['levee' => true]);
$this->travel(2)->seconds();
eleve($this, $b)->postJson(route('seances.visio.eleve.main', $seance), ['levee' => true]);
$etat = prof($this, $seance->classe->prof)->getJson(route('seances.visio.etat', $seance))->json();
expect(collect($etat['mains'])->pluck('nom')->all())->toBe(['Yanis B.', 'Adam D.'])
->and($etat['mains'][0]['participant_id'])->toBe('abc123');
prof($this, $seance->classe->prof)->postJson(route('seances.visio.parole', [$seance, $a]))->assertOk();
expect(collect(prof($this, $seance->classe->prof)->getJson(route('seances.visio.etat', $seance))->json('mains'))->pluck('nom')->all())->toBe(['Adam D.'])
->and(eleve($this, $a)->getJson(route('seances.visio.eleve.etat', $seance))->json('main_levee'))->toBeFalse();
});
test('la salle d’attente montre les élèves présents tant que la visio est fermée', function () {
$seance = seanceAvecClasse();
$lea = Eleve::factory()->create(['classe_id' => $seance->classe_id, 'prenom' => 'Léa', 'nom' => 'M']);
eleve($this, $lea)->getJson(route('seances.visio.eleve.etat', $seance))->assertJson(['ouverte' => false]);
expect(prof($this, $seance->classe->prof)->getJson(route('seances.visio.etat', $seance))->json('attente'))->toBe([['nom' => 'Léa M.']]);
});
test('les scripts des pages de visio sont du JavaScript valide', function () {
if (Process::run('node --version')->failed()) {
$this->markTestSkipped('Node absent');
}
$seance = seanceAvecClasse();
$seance->update(['visio_ouverte_a' => now(), 'visio_salle' => 'syllabus-x']);
$lea = Eleve::factory()->create(['classe_id' => $seance->classe_id]);
$pages = [
prof($this, $seance->classe->prof)->get(route('seances.visio.prof', $seance))->getContent(),
eleve($this, $lea)->get(route('seances.visio.eleve', $seance))->getContent(),
];
foreach ($pages as $html) {
preg_match_all('#<script(?![^>]*\bsrc=)[^>]*>(.*?)</script>#s', $html, $scripts);
foreach ($scripts[1] as $code) {
$fichier = tempnam(sys_get_temp_dir(), 'js').'.js';
file_put_contents($fichier, $code);
$r = Process::run(['node', '--check', $fichier]);
unlink($fichier);
expect($r->successful())->toBeTrue($r->errorOutput());
}
}
});