140 lines
6.9 KiB
PHP
140 lines
6.9 KiB
PHP
|
|
<?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());
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|