Paillasse/portail/tests/Feature/ApiV1Test.php
Adam Belkacemi ecf3dfb21d ajoute l'API publique /api/v1 (bloc B, premier chantier)
Catalogue en lecture seule (labs, banque de quiz officielle, cours) -
choix d'Adam pour la première version, zéro donnée d'élève. Laravel
Sanctum, un jeton Bearer par développeur créé depuis /jetons-api (un
prof), portée catalogue:lire seulement. Quota par jeton (pas par
compte ni par IP) dans Redis via un limiteur nommé, 60 requêtes/minute.
Documentation : spécification OpenAPI 3.0 et page publique /api/docs.

Désactive /sanctum/csrf-cookie (config/sanctum.php) : l'API est en
jeton Bearer seulement, jamais en cookie de SPA.

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

113 lines
4.6 KiB
PHP

<?php
use App\Models\Classe;
use App\Models\Cours;
use App\Models\Lab;
use App\Models\Quiz;
use App\Models\User;
function jetonApi(User $prof, array $abilities = ['catalogue:lire']): string
{
return $prof->createToken('test', $abilities)->plainTextToken;
}
test('aucune requête sans jeton', function () {
$this->getJson('/api/v1/labs')->assertUnauthorized();
});
test("un jeton sans l'ability catalogue:lire est refusé", function () {
$jeton = jetonApi(User::factory()->create(), []);
$this->withToken($jeton)->getJson('/api/v1/labs')->assertForbidden();
});
test('un jeton révoqué ne fonctionne plus', function () {
$prof = User::factory()->create();
$jeton = jetonApi($prof);
$prof->tokens()->delete();
$this->withToken($jeton)->getJson('/api/v1/labs')->assertUnauthorized();
});
test('liste et détail des labs', function () {
$jeton = jetonApi(User::factory()->create());
Lab::factory()->create(['identifiant' => '08-deux-lan-routeur', 'titre' => 'Deux LAN et un routeur', 'competences' => ['routage-statique']]);
$this->withToken($jeton)->getJson('/api/v1/labs')
->assertOk()
->assertJsonFragment(['identifiant' => '08-deux-lan-routeur']);
$this->withToken($jeton)->getJson('/api/v1/labs/08-deux-lan-routeur')
->assertOk()
->assertJson(['data' => ['identifiant' => '08-deux-lan-routeur', 'titre' => 'Deux LAN et un routeur', 'competences' => ['routage-statique']]]);
$this->withToken($jeton)->getJson('/api/v1/labs/inconnu')->assertNotFound();
});
test('la liste des quiz ne montre que la banque officielle (partagés, avec thème), jamais les questions', function () {
$jeton = jetonApi(User::factory()->create());
$classe = Classe::factory()->create();
$officiel = Quiz::create(['classe_id' => $classe->id, 'titre' => 'Officiel', 'theme' => 'NET', 'partage' => true]);
$officiel->questions()->create(['enonce' => 'Une question', 'type' => 'vrai_faux', 'ordre' => 0, 'reponse_vrai_faux' => true]);
Quiz::create(['classe_id' => $classe->id, 'titre' => 'Partagé sans thème', 'theme' => null, 'partage' => true]);
Quiz::create(['classe_id' => $classe->id, 'titre' => 'Privé', 'theme' => 'NET', 'partage' => false]);
$reponse = $this->withToken($jeton)->getJson('/api/v1/quiz')->assertOk();
$titres = collect($reponse->json('data'))->pluck('titre');
expect($titres->all())->toBe(['Officiel'])
->and($reponse->json('data.0'))->not->toHaveKey('questions')
->and($reponse->json('data.0.questions_count'))->toBe(1);
$this->withToken($jeton)->getJson("/api/v1/quiz/{$officiel->id}")->assertOk();
$prive = Quiz::where('titre', 'Privé')->first();
$this->withToken($jeton)->getJson("/api/v1/quiz/{$prive->id}")->assertNotFound();
});
test('liste et détail des cours : le contenu complet seulement sur le détail', function () {
$jeton = jetonApi(User::factory()->create());
Cours::create(['identifiant' => '08-deux-lan-routeur', 'titre' => 'Deux LAN et un routeur', 'contenu_markdown' => '# Cours']);
$this->withToken($jeton)->getJson('/api/v1/cours')
->assertOk()
->assertJsonMissing(['contenu_markdown' => '# Cours']);
$this->withToken($jeton)->getJson('/api/v1/cours/08-deux-lan-routeur')
->assertOk()
->assertJson(['data' => ['contenu_markdown' => '# Cours']]);
});
test('chaque réponse porte les en-têtes de limite de débit', function () {
$jeton = jetonApi(User::factory()->create());
$this->withToken($jeton)->getJson('/api/v1/labs')
->assertHeader('X-RateLimit-Limit', 60);
});
test('un prof crée un jeton, le voit en clair une seule fois, le révoque', function () {
$prof = User::factory()->create();
$page = prof($this, $prof)->post(route('jetons-api.store'), ['nom' => 'Mon script'])->assertRedirect();
$page = prof($this, $prof)->get(route('jetons-api.index'));
$page->assertOk()->assertSee('Mon script');
expect($prof->tokens()->count())->toBe(1);
$jeton = $prof->tokens()->first();
prof($this, $prof)->get(route('jetons-api.index'))->assertDontSee($jeton->token);
prof($this, $prof)->post(route('jetons-api.destroy', $jeton))->assertRedirect();
expect($prof->tokens()->count())->toBe(0);
});
test("un prof ne peut pas révoquer le jeton d'un autre prof", function () {
$prof = User::factory()->create();
$autreProf = User::factory()->create();
jetonApi($autreProf);
$jeton = $autreProf->tokens()->first();
prof($this, $prof)->post(route('jetons-api.destroy', $jeton))->assertForbidden();
expect($autreProf->tokens()->count())->toBe(1);
});