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>
This commit is contained in:
parent
dd4f9c3c55
commit
ec4eae3744
36 changed files with 1406 additions and 18 deletions
|
|
@ -5,7 +5,8 @@ test('les pages légales sont en ligne et la page de statut redirige vers le sit
|
|||
$this->get(route('confidentialite'))->assertOk()
|
||||
->assertSee('90 jours après l’archivage', false)
|
||||
->assertSee('aucune adresse e-mail', false)
|
||||
->assertSee('cdn.tailwindcss.com');
|
||||
->assertSee('cdn.tailwindcss.com')
|
||||
->assertSee('sans être enregistrés', false);
|
||||
$this->get(route('statut'))->assertRedirect('https://status.syllabuslab.fr');
|
||||
});
|
||||
|
||||
|
|
|
|||
139
portail/tests/Feature/VisioTest.php
Normal file
139
portail/tests/Feature/VisioTest.php
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
<?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());
|
||||
}
|
||||
}
|
||||
});
|
||||
31
portail/tests/Reels/preparer-visio.php
Normal file
31
portail/tests/Reels/preparer-visio.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
// Données de test de la visio (étape 15), sur le serveur :
|
||||
// sudo -u www-data HOME=/tmp php8.3 artisan tinker tests/Reels/preparer-visio.php
|
||||
// Identifiants dans un fichier 600, jamais affichés. À supprimer après le test.
|
||||
|
||||
use App\Models\Classe;
|
||||
use App\Models\Eleve;
|
||||
use App\Models\Etablissement;
|
||||
use App\Models\Lab;
|
||||
use App\Models\Seance;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
$etablissement = Etablissement::create(['nom' => 'Lycée Test Visio', 'ville' => 'Test']);
|
||||
$motDePasse = Str::password(24, symbols: false);
|
||||
$prof = User::create(['name' => 'Prof Visio', 'email' => 'prof-visio@syllabus.test', 'password' => $motDePasse, 'etablissement_id' => $etablissement->id]);
|
||||
$classe = Classe::create(['etablissement_id' => $etablissement->id, 'user_id' => $prof->id, 'nom' => 'Classe Test Visio', 'formation' => 'Test']);
|
||||
$eleves = [];
|
||||
foreach ([['Lea', 'Martin', 'lea.visio'], ['Yanis', 'Bernard', 'yanis.visio']] as [$prenom, $nom, $identifiant]) {
|
||||
$code = Str::upper(Str::random(6));
|
||||
Eleve::create(['classe_id' => $classe->id, 'prenom' => $prenom, 'nom' => $nom, 'identifiant' => $identifiant, 'code_secret' => Hash::make($code), 'jeton_qr' => Str::random(32)]);
|
||||
$eleves[] = ['identifiant' => $identifiant, 'code' => $code];
|
||||
}
|
||||
$seance = Seance::create(['classe_id' => $classe->id, 'lab_id' => Lab::where('identifiant', '08-deux-lan-routeur')->value('id'), 'statut' => Seance::SALLE_ATTENTE, 'demarree_a' => now()]);
|
||||
|
||||
$fichier = '/tmp/visio-identifiants.json';
|
||||
file_put_contents($fichier, json_encode(['prof' => ['email' => $prof->email, 'mot_de_passe' => $motDePasse], 'eleves' => $eleves, 'seance_id' => $seance->id]));
|
||||
chmod($fichier, 0600);
|
||||
echo "données de test prêtes (séance {$seance->id})\n";
|
||||
91
portail/tests/Reels/visio-e2e.mjs
Normal file
91
portail/tests/Reels/visio-e2e.mjs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// Critère de l'étape 15, dans de vrais navigateurs (Chromium, caméra et micro
|
||||
// simulés) : un prof et deux élèves rejoignent la visio ; un élève lève la
|
||||
// main ; le prof lui donne la parole ; un intrus sans jeton ne peut pas entrer.
|
||||
// node visio-e2e.mjs <fichier d'identifiants> [https://syllabuslab.fr]
|
||||
import { chromium } from 'playwright';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const ids = JSON.parse(readFileSync(process.argv[2], 'utf8'));
|
||||
const BASE = process.argv[3] ?? 'https://syllabuslab.fr';
|
||||
const S = ids.seance_id;
|
||||
const navigateur = await chromium.launch({ args: ['--use-fake-ui-for-media-stream', '--use-fake-device-for-media-stream'] });
|
||||
const contexte = () => navigateur.newContext({ locale: 'fr-FR', permissions: ['camera', 'microphone'] });
|
||||
const etape = (texte) => console.log(`${new Date().toISOString().slice(11, 19)} ${texte}`);
|
||||
const attendre = async (fn, delai = 30000, pas = 500) => {
|
||||
const fin = Date.now() + delai;
|
||||
while (Date.now() < fin) { const r = await fn(); if (r) return r; await new Promise((ok) => setTimeout(ok, pas)); }
|
||||
throw new Error('délai dépassé');
|
||||
};
|
||||
|
||||
async function connexion(page, champs, bouton) {
|
||||
await page.goto(`${BASE}/connexion`);
|
||||
for (const [nom, valeur] of Object.entries(champs)) await page.fill(`input[name="${nom}"]`, valeur);
|
||||
await page.click(bouton);
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Le prof ouvre la visio.
|
||||
const prof = await (await contexte()).newPage();
|
||||
await connexion(prof, { email: ids.prof.email, password: ids.prof.mot_de_passe }, 'form[action$="/connexion/prof"] button[type=submit]');
|
||||
await prof.goto(`${BASE}/seances/${S}/visio`);
|
||||
if (await prof.locator('text=Ouvrir la visio').count()) await prof.click('text=Ouvrir la visio');
|
||||
await prof.waitForSelector('#v-scene iframe', { timeout: 30000 });
|
||||
etape('prof : visio ouverte, Jitsi chargé');
|
||||
|
||||
// 2. Deux élèves la rejoignent.
|
||||
const eleves = [];
|
||||
for (const e of ids.eleves) {
|
||||
const page = await (await contexte()).newPage();
|
||||
await connexion(page, { identifiant: e.identifiant, code: e.code }, 'form[action$="/connexion/eleve"] button[type=submit]');
|
||||
await page.goto(`${BASE}/seances/${S}/visio/eleve`);
|
||||
await page.waitForSelector('#v-scene iframe', { timeout: 30000 });
|
||||
eleves.push(page);
|
||||
}
|
||||
const enLigne = await attendre(async () => {
|
||||
const t = await prof.textContent('#v-nb');
|
||||
return /3 en ligne/.test(t ?? '') && t;
|
||||
}, 60000);
|
||||
etape(`prof : ${enLigne.trim()} (le prof et les deux élèves)`);
|
||||
|
||||
// 3. Léa lève la main ; elle apparaît chez le prof.
|
||||
const [lea, yanis] = eleves;
|
||||
await lea.click('[data-main-e]');
|
||||
await attendre(async () => (await prof.locator('#v-mains [data-parole]').count()) === 1, 30000);
|
||||
etape(`prof : main levée reçue de « ${(await prof.locator('#v-mains strong').first().textContent())} »`);
|
||||
|
||||
// 4. Yanis, sans la parole, ne peut pas réactiver son micro.
|
||||
await yanis.click('[data-micro-e]');
|
||||
await new Promise((ok) => setTimeout(ok, 4000));
|
||||
const statutYanis = (await yanis.textContent('[data-statut-e]')).trim();
|
||||
etape(`Yanis tente d'ouvrir son micro sans la parole → « ${statutYanis} »`);
|
||||
|
||||
// 5. Le prof donne la parole à Léa : elle peut ouvrir son micro.
|
||||
await prof.click('#v-mains [data-parole]');
|
||||
await attendre(async () => (await lea.getAttribute('[data-main-e]', 'aria-pressed')) === 'false', 30000);
|
||||
etape('Léa : sa main est baissée (parole donnée)');
|
||||
await lea.click('[data-micro-e]');
|
||||
const statutLea = await attendre(async () => {
|
||||
const t = (await lea.textContent('[data-statut-e]')).trim();
|
||||
return t === 'Tu as la parole' && t;
|
||||
}, 30000);
|
||||
etape(`Léa ouvre son micro → « ${statutLea} »`);
|
||||
|
||||
// 6. Un intrus, sans jeton, tente d'entrer directement dans la salle.
|
||||
const salle = await prof.evaluate(() => document.querySelector('#v-scene iframe').src.split('?')[0].split('#')[0].split('/').pop());
|
||||
const intrus = await (await contexte()).newPage();
|
||||
await intrus.goto(`${BASE.replace('://', '://meet.')}/${salle}`);
|
||||
await new Promise((ok) => setTimeout(ok, 12000));
|
||||
const toujours = (await prof.textContent('#v-nb')).trim();
|
||||
const ecranIntrus = (await intrus.locator('body').innerText()).replace(/\s+/g, ' ').slice(0, 160);
|
||||
etape(`intrus sans jeton : le prof voit toujours « ${toujours} » ; écran de l'intrus : « ${ecranIntrus} »`);
|
||||
|
||||
const ok = /3 en ligne/.test(toujours) && statutYanis === 'En sourdine' && statutLea === 'Tu as la parole';
|
||||
console.log(ok ? 'CRITÈRE DE L’ÉTAPE 15 : RÉUSSI' : 'CRITÈRE DE L’ÉTAPE 15 : ÉCHOUÉ');
|
||||
process.exitCode = ok ? 0 : 1;
|
||||
} catch (e) {
|
||||
console.error('ÉCHEC :', e.message);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await navigateur.close();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue