Paillasse/portail/tests/Feature/LtiTest.php
Adam Belkacemi 91d7cde4c1 ajoute Moodle LTI 1.3 (V2), une 6e façon de rejoindre une classe
LTI authentifie l'élève et le fait rejoindre sa classe (même code que
le code/QR existant), pas lancer un lab précis - relecture du
CLAUDE.md qui liste LTI avec les autres façons d'ajouter des élèves.
packbackbooks/lti-1p3-tool (Apache-2.0, recommandé par le CLAUDE.md),
trois ponts vers Redis/cookies/MariaDB, élève retrouvé par identité
Moodle stable pour ne jamais créer de doublon. Enregistrement d'une
plateforme/d'un déploiement par commande Artisan (opération rare,
faite à la main par Adam), jamais une page d'administration.

Corrige au passage un vrai bug préexistant trouvé en vérifiant en réel
sur le serveur : withoutMiddleware(ValidateCsrfToken::class) sur une
route ne retire jamais le vrai middleware CSRF (ValidateCsrfToken est
un alias déprécié de PreventRequestForgery, pas la classe réellement
enregistrée) - /csp-rapport avait le même défaut depuis l'étape 14,
invisible faute de surveillance. Corrigé pour les deux par la bonne
méthode (validateCsrfTokens except, dans bootstrap/app.php).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-14 13:51:47 +02:00

167 lines
6.4 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\LtiDeploiement;
use App\Models\LtiPlateforme;
use Firebase\JWT\JWT;
use GuzzleHttp\Psr7\Response;
use Illuminate\Support\Facades\Cookie;
use Mockery\MockInterface;
use Packback\Lti1p3\Claims\DeploymentId;
use Packback\Lti1p3\Claims\MessageType;
use Packback\Lti1p3\Claims\ResourceLink;
use Packback\Lti1p3\Claims\Roles;
use Packback\Lti1p3\Claims\TargetLinkUri;
use Packback\Lti1p3\Claims\Version;
use Packback\Lti1p3\Interfaces\ICache;
use Packback\Lti1p3\Interfaces\ILtiServiceConnector;
use Packback\Lti1p3\LtiConstants;
use Packback\Lti1p3\LtiOidcLogin;
use phpseclib4\Crypt\RSA;
function creerPlateformeLtiDeTest(): array
{
$cle = RSA::createKey(2048);
$plateforme = LtiPlateforme::create([
'emetteur' => 'https://moodle-test.exemple.fr',
'client_id' => 'client-test',
'url_jeu_cles' => 'https://moodle-test.exemple.fr/jwks',
'url_jeton_auth' => 'https://moodle-test.exemple.fr/token',
'url_connexion_auth' => 'https://moodle-test.exemple.fr/auth',
'cle_privee_outil' => $cle->toString('PKCS8'),
'identifiant_cle' => 'kid-portail',
]);
$deploiement = LtiDeploiement::create([
'lti_plateforme_id' => $plateforme->id,
'deployment_id' => 'depl-1',
]);
return [$plateforme, $deploiement, $cle];
}
function jwkMoodleDeTest($clePlateforme, string $kid): array
{
$jwk = json_decode($clePlateforme->getPublicKey()->toString('JWK'), true);
return array_merge($jwk['keys'][0], ['kid' => $kid, 'alg' => 'RS256', 'use' => 'sig']);
}
function jeuClésMoodleFausse(array $jwk): void
{
/** @var MockInterface $connecteur */
$connecteur = Mockery::mock(ILtiServiceConnector::class);
$connecteur->shouldReceive('makeRequest')->andReturn(new Response(200));
$connecteur->shouldReceive('getResponseBody')->andReturn(['keys' => [$jwk]]);
app()->instance(ILtiServiceConnector::class, $connecteur);
}
test('un lancement vers une classe inconnue affiche une erreur, jamais une 500', function () {
$this->post(route('lti.lancement', 'CODEINEXISTANT'))->assertOk()->assertSee('introuvable');
});
test('un lancement vers une classe archivée affiche une erreur', function () {
$classe = Classe::factory()->create(['archivee_a' => now()]);
$this->post(route('lti.lancement', $classe->code_classe))->assertOk()->assertSee('introuvable');
});
test('un id_token sans plateforme enregistrée affiche une erreur claire', function () {
$classe = Classe::factory()->create();
$this->post(route('lti.lancement', $classe->code_classe), ['id_token' => 'x.y.z', 'state' => 'abc'])
->assertOk()
->assertDontSee('Server Error');
});
test('un lancement valide crée l’élève (identité stable via lti_sub) et le connecte', function () {
[$plateforme, $deploiement, $cle] = creerPlateformeLtiDeTest();
$classe = Classe::factory()->create();
$etat = 'etat-de-test';
$nonce = 'nonce-de-test';
Cookie::queue(LtiOidcLogin::COOKIE_PREFIX.$etat, $etat, 1);
app(ICache::class)->cacheNonce($nonce, $etat);
jeuClésMoodleFausse(jwkMoodleDeTest($cle, $plateforme->identifiant_cle));
$payload = [
'iss' => $plateforme->emetteur,
'aud' => $plateforme->client_id,
'sub' => 'etu-externe-42',
'exp' => now()->addMinutes(5)->timestamp,
'iat' => now()->timestamp,
'nonce' => $nonce,
'given_name' => 'Camille',
'family_name' => 'Dupont',
Version::claimKey() => LtiConstants::V1_3,
DeploymentId::claimKey() => $deploiement->deployment_id,
MessageType::claimKey() => 'LtiResourceLinkRequest',
Roles::claimKey() => [],
TargetLinkUri::claimKey() => route('lti.lancement', $classe->code_classe),
ResourceLink::claimKey() => ['id' => 'rl-1'],
];
$idToken = JWT::encode($payload, $cle->toString('PKCS8'), 'RS256', $plateforme->identifiant_cle);
$reponse = $this->withCookie(LtiOidcLogin::COOKIE_PREFIX.$etat, $etat)
->post(route('lti.lancement', $classe->code_classe), ['id_token' => $idToken, 'state' => $etat]);
$reponse->assertRedirect(route('eleve'));
$eleve = Eleve::where('classe_id', $classe->id)->first();
expect($eleve)->not->toBeNull()
->and($eleve->lti_emetteur)->toBe($plateforme->emetteur)
->and($eleve->lti_sub)->toBe('etu-externe-42')
->and($eleve->prenom)->toBe('Camille')
->and($eleve->nom)->toBe('Dupont');
});
test('un deuxième lancement du même élève ne crée pas de deuxième compte', function () {
[$plateforme, , $cle] = creerPlateformeLtiDeTest();
$classe = Classe::factory()->create();
$eleveExistant = Eleve::factory()->create([
'classe_id' => $classe->id,
'lti_emetteur' => $plateforme->emetteur,
'lti_sub' => 'etu-externe-42',
]);
$etat = 'etat-2';
$nonce = 'nonce-2';
Cookie::queue(LtiOidcLogin::COOKIE_PREFIX.$etat, $etat, 1);
app(ICache::class)->cacheNonce($nonce, $etat);
jeuClésMoodleFausse(jwkMoodleDeTest($cle, $plateforme->identifiant_cle));
$payload = [
'iss' => $plateforme->emetteur,
'aud' => $plateforme->client_id,
'sub' => 'etu-externe-42',
'exp' => now()->addMinutes(5)->timestamp,
'iat' => now()->timestamp,
'nonce' => $nonce,
Version::claimKey() => LtiConstants::V1_3,
DeploymentId::claimKey() => LtiDeploiement::where('lti_plateforme_id', $plateforme->id)->first()->deployment_id,
MessageType::claimKey() => 'LtiResourceLinkRequest',
Roles::claimKey() => [],
TargetLinkUri::claimKey() => route('lti.lancement', $classe->code_classe),
ResourceLink::claimKey() => ['id' => 'rl-1'],
];
$idToken = JWT::encode($payload, $cle->toString('PKCS8'), 'RS256', $plateforme->identifiant_cle);
$this->withCookie(LtiOidcLogin::COOKIE_PREFIX.$etat, $etat)
->post(route('lti.lancement', $classe->code_classe), ['id_token' => $idToken, 'state' => $etat])
->assertRedirect(route('eleve'));
expect(Eleve::where('classe_id', $classe->id)->count())->toBe(1);
expect(Eleve::first()->id)->toBe($eleveExistant->id);
});
test('les clés publiques du portail sont accessibles sans authentification', function () {
creerPlateformeLtiDeTest();
$this->get(route('lti.cles'))->assertOk()->assertJsonStructure(['keys']);
});
test('la connexion LTI exige une target_link_uri', function () {
$this->post(route('lti.connexion'), [])->assertStatus(400);
});