'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('#]*\bsrc=)[^>]*>(.*?)#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()); } } });