'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); });