ajoute le stockage S3 des devoirs maison (bloc B, dernier chantier)

Dépôt de fichier en plus du texte libre pour les devoirs maison, stocké
sur Hetzner Object Storage (jamais sur le disque du serveur), masqué
côté élève tant que le bucket n'est pas configuré. Purge RGPD étendue
pour supprimer aussi les fichiers d'Object Storage. Ansible lit les
identifiants depuis un fichier que seul Adam peut créer sur le serveur
(runbook docs/runbooks/stockage-s3.md) ; le bloc B du CLAUDE.md est
maintenant complet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Adam Belkacemi 2026-10-14 09:52:15 +02:00
parent 79d4a06fdf
commit 6ea7a1cc16
16 changed files with 1022 additions and 59 deletions

View file

@ -0,0 +1,147 @@
<?php
use App\Models\Classe;
use App\Models\Devoir;
use App\Models\Eleve;
use App\Models\RenduDevoir;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
/** Active le disque s3 factice et le fait passer pour configuré. */
function avecDisqueS3Factice(): void
{
config(['filesystems.disks.s3.bucket' => 'bucket-de-test']);
Storage::fake('s3');
}
test('tant que le bucket s3 n’est pas configuré, le dépôt de fichier est masqué et refusé', function () {
$classe = Classe::factory()->create();
$eleve = Eleve::factory()->create(['classe_id' => $classe->id]);
$devoir = Devoir::create(['classe_id' => $classe->id, 'titre' => 'Test', 'consigne' => 'Test', 'echeance' => now()->addDay()]);
eleve($this, $eleve)->get(route('devoirs.eleve.show', $devoir))->assertOk()->assertDontSee('Joindre un fichier');
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), [
'fichier' => UploadedFile::fake()->create('devoir.pdf', 100, 'application/pdf'),
])->assertStatus(503);
});
test('un élève peut rendre un devoir avec un fichier seul, sans texte', function () {
avecDisqueS3Factice();
$classe = Classe::factory()->create();
$eleve = Eleve::factory()->create(['classe_id' => $classe->id]);
$devoir = Devoir::create(['classe_id' => $classe->id, 'titre' => 'Test', 'consigne' => 'Test', 'echeance' => now()->addDay()]);
eleve($this, $eleve)->get(route('devoirs.eleve.show', $devoir))->assertOk()->assertSee('Joindre un fichier');
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), [
'fichier' => UploadedFile::fake()->create('devoir.pdf', 100, 'application/pdf'),
])->assertRedirect();
$rendu = RenduDevoir::where('devoir_id', $devoir->id)->where('eleve_id', $eleve->id)->first();
expect($rendu)->not->toBeNull();
expect($rendu->reponse)->toBeNull();
expect($rendu->aUnFichier())->toBeTrue();
expect($rendu->fichier_nom_original)->toBe('devoir.pdf');
Storage::disk('s3')->assertExists($rendu->fichier_chemin);
});
test('un rendu sans réponse ni fichier est refusé', function () {
avecDisqueS3Factice();
$classe = Classe::factory()->create();
$eleve = Eleve::factory()->create(['classe_id' => $classe->id]);
$devoir = Devoir::create(['classe_id' => $classe->id, 'titre' => 'Test', 'consigne' => 'Test', 'echeance' => now()->addDay()]);
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), [])->assertSessionHasErrors(['reponse', 'fichier']);
expect(RenduDevoir::count())->toBe(0);
});
test('un fichier trop lourd ou d’une extension refusée est rejeté', function () {
avecDisqueS3Factice();
$classe = Classe::factory()->create();
$eleve = Eleve::factory()->create(['classe_id' => $classe->id]);
$devoir = Devoir::create(['classe_id' => $classe->id, 'titre' => 'Test', 'consigne' => 'Test', 'echeance' => now()->addDay()]);
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), [
'fichier' => UploadedFile::fake()->create('trop-gros.pdf', 11 * 1024, 'application/pdf'),
])->assertSessionHasErrors('fichier');
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), [
'fichier' => UploadedFile::fake()->create('script.exe', 10, 'application/x-msdownload'),
])->assertSessionHasErrors('fichier');
expect(RenduDevoir::count())->toBe(0);
});
test('remplacer son fichier supprime l’ancien de l’object storage, un nouvel envoi en texte seul le conserve', function () {
avecDisqueS3Factice();
$classe = Classe::factory()->create();
$eleve = Eleve::factory()->create(['classe_id' => $classe->id]);
$devoir = Devoir::create(['classe_id' => $classe->id, 'titre' => 'Test', 'consigne' => 'Test', 'echeance' => now()->addDay()]);
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), [
'fichier' => UploadedFile::fake()->create('premier.pdf', 100, 'application/pdf'),
])->assertRedirect();
$cheminPremier = RenduDevoir::first()->fichier_chemin;
Storage::disk('s3')->assertExists($cheminPremier);
// Un nouvel envoi en texte seul (pas de nouveau fichier) garde l'ancien fichier.
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), ['reponse' => 'Je précise ma réponse'])->assertRedirect();
$rendu = RenduDevoir::first();
expect($rendu->fichier_chemin)->toBe($cheminPremier)->and($rendu->reponse)->toBe('Je précise ma réponse');
Storage::disk('s3')->assertExists($cheminPremier);
// Un nouveau fichier remplace l'ancien, qui est supprimé de l'object storage.
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), [
'fichier' => UploadedFile::fake()->create('second.pdf', 100, 'application/pdf'),
])->assertRedirect();
Storage::disk('s3')->assertMissing($cheminPremier);
expect(RenduDevoir::count())->toBe(1);
});
test('seul le prof propriétaire peut télécharger le fichier rendu par un élève', function () {
avecDisqueS3Factice();
$classe = Classe::factory()->create();
$eleve = Eleve::factory()->create(['classe_id' => $classe->id]);
$devoir = Devoir::create(['classe_id' => $classe->id, 'titre' => 'Test', 'consigne' => 'Test', 'echeance' => now()->addDay()]);
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), [
'fichier' => UploadedFile::fake()->create('devoir.pdf', 100, 'application/pdf'),
])->assertRedirect();
$rendu = RenduDevoir::first();
commeProf($this, $classe->prof)->get(route('mes-classes.devoirs.telecharger', [$classe, $devoir, $rendu]))->assertRedirect();
$autreClasse = Classe::factory()->create();
commeProf($this, $autreClasse->prof)->get(route('mes-classes.devoirs.telecharger', [$classe, $devoir, $rendu]))->assertForbidden();
});
test('le prof voit le fichier déposé par un élève dans la page du devoir', function () {
avecDisqueS3Factice();
$classe = Classe::factory()->create();
$eleve = Eleve::factory()->create(['classe_id' => $classe->id]);
$devoir = Devoir::create(['classe_id' => $classe->id, 'titre' => 'Test', 'consigne' => 'Test', 'echeance' => now()->addDay()]);
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), [
'fichier' => UploadedFile::fake()->create('rapport-reseau.pdf', 100, 'application/pdf'),
])->assertRedirect();
commeProf($this, $classe->prof)->get(route('mes-classes.devoirs.show', [$classe, $devoir]))
->assertSee('rapport-reseau.pdf');
});
test('purger une classe archivée supprime aussi les fichiers de ses rendus sur l’object storage', function () {
avecDisqueS3Factice();
$classe = Classe::factory()->create();
$eleve = Eleve::factory()->create(['classe_id' => $classe->id]);
$devoir = Devoir::create(['classe_id' => $classe->id, 'titre' => 'Test', 'consigne' => 'Test', 'echeance' => now()->addDay()]);
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), [
'fichier' => UploadedFile::fake()->create('devoir.pdf', 100, 'application/pdf'),
])->assertRedirect();
$chemin = RenduDevoir::first()->fichier_chemin;
Storage::disk('s3')->assertExists($chemin);
$classe->forceFill(['archivee_a' => now()->subDays(91)])->save();
$this->artisan('classes:purger-archivees');
Storage::disk('s3')->assertMissing($chemin);
expect(RenduDevoir::count())->toBe(0);
});