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>
147 lines
7.3 KiB
PHP
147 lines
7.3 KiB
PHP
<?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);
|
||
});
|