feat: module budgets complet avec sécurité, performance et métier
## Fonctionnalités - Module Budgets : enveloppes, lignes budgétaires, arbitrage DSI/Direction - Suivi de l'exécution budgétaire avec alertes visuelles (dépassement, seuil 80%) - Blocage des commandes si budget insuffisant (store + update) - Audit trail complet des arbitrages via HistoriqueBudget - Page d'index budgets refaite en tableau avec filtres et tri côté client - Page Services avec sélecteur d'icônes FontAwesome (solid + regular + brands) ## Sécurité - BudgetPolicy centralisée (viewAny, view, create, update, addLigne, updateLigne, deleteLigne, arbitrerLigne) - Autorisation sur tous les endpoints LigneBudget et Budget - Protection XSS : remplacement v-html par classes dynamiques - Validation des paramètres d'export (type, envelope) - Validation montant_arbitre ≤ montant_propose côté serveur ## Performance - Eager loading lignes.commandes.commune dans execution() et exportPdf() - Calculs montant_consomme/engage en mémoire sur collections déjà chargées - Null-safety sur montant_arbitre dans getMontantDisponibleAttribute ## Technique - Migration historique_budgets, budgets, ligne_budgets, rôle raf - SearchableSelect avec affichage du disponible budgétaire - FontAwesome enregistré globalement (fas, far, fab) - 33 tests Feature (sécurité, performance, métier) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
354
tests/Feature/BudgetSecurityTest.php
Normal file
354
tests/Feature/BudgetSecurityTest.php
Normal file
@@ -0,0 +1,354 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Budget;
|
||||
use App\Models\LigneBudget;
|
||||
use App\Models\Service;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Tests\TestCase;
|
||||
|
||||
class BudgetSecurityTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function createRole(string $name): Role
|
||||
{
|
||||
return Role::firstOrCreate(['name' => $name, 'guard_name' => 'web']);
|
||||
}
|
||||
|
||||
private function userWithRole(string $role, ?Service $service = null): User
|
||||
{
|
||||
$service ??= Service::factory()->create();
|
||||
$user = User::factory()->create(['service_id' => $service->id, 'email_verified_at' => now()]);
|
||||
$user->assignRole($this->createRole($role));
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function makeBudget(Service $service, string $statut = 'preparation'): Budget
|
||||
{
|
||||
return Budget::create([
|
||||
'service_id' => $service->id,
|
||||
'annee' => 2026,
|
||||
'type_budget' => 'agglo',
|
||||
'statut' => $statut,
|
||||
]);
|
||||
}
|
||||
|
||||
private function makeLigne(Budget $budget): LigneBudget
|
||||
{
|
||||
return LigneBudget::create([
|
||||
'budget_id' => $budget->id,
|
||||
'nom' => 'Ligne test',
|
||||
'type_depense' => 'investissement',
|
||||
'montant_propose' => 1000,
|
||||
]);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// LigneBudgetController::destroy — faille critique
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function test_responsable_can_delete_ligne_of_own_service(): void
|
||||
{
|
||||
$service = Service::factory()->create();
|
||||
$user = $this->userWithRole('responsable', $service);
|
||||
$budget = $this->makeBudget($service);
|
||||
$ligne = $this->makeLigne($budget);
|
||||
|
||||
$response = $this->actingAs($user)->delete(route('lignes-budget.destroy', $ligne));
|
||||
|
||||
$response->assertRedirect();
|
||||
$this->assertDatabaseMissing('ligne_budgets', ['id' => $ligne->id]);
|
||||
}
|
||||
|
||||
public function test_responsable_cannot_delete_ligne_of_another_service(): void
|
||||
{
|
||||
$otherService = Service::factory()->create();
|
||||
$myService = Service::factory()->create();
|
||||
$user = $this->userWithRole('responsable', $myService);
|
||||
$budget = $this->makeBudget($otherService);
|
||||
$ligne = $this->makeLigne($budget);
|
||||
|
||||
$response = $this->actingAs($user)->delete(route('lignes-budget.destroy', $ligne));
|
||||
|
||||
$response->assertForbidden();
|
||||
$this->assertDatabaseHas('ligne_budgets', ['id' => $ligne->id]);
|
||||
}
|
||||
|
||||
public function test_unauthenticated_user_cannot_delete_ligne(): void
|
||||
{
|
||||
$service = Service::factory()->create();
|
||||
$budget = $this->makeBudget($service);
|
||||
$ligne = $this->makeLigne($budget);
|
||||
|
||||
$response = $this->delete(route('lignes-budget.destroy', $ligne));
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
$this->assertDatabaseHas('ligne_budgets', ['id' => $ligne->id]);
|
||||
}
|
||||
|
||||
public function test_cannot_delete_ligne_when_budget_is_locked(): void
|
||||
{
|
||||
$service = Service::factory()->create();
|
||||
$user = $this->userWithRole('responsable', $service);
|
||||
$budget = $this->makeBudget($service, 'valide');
|
||||
$ligne = $this->makeLigne($budget);
|
||||
|
||||
$response = $this->actingAs($user)->delete(route('lignes-budget.destroy', $ligne));
|
||||
|
||||
$response->assertForbidden();
|
||||
$this->assertDatabaseHas('ligne_budgets', ['id' => $ligne->id]);
|
||||
}
|
||||
|
||||
public function test_admin_can_delete_ligne_of_any_service(): void
|
||||
{
|
||||
$otherService = Service::factory()->create();
|
||||
$admin = $this->userWithRole('admin');
|
||||
$budget = $this->makeBudget($otherService);
|
||||
$ligne = $this->makeLigne($budget);
|
||||
|
||||
$response = $this->actingAs($admin)->delete(route('lignes-budget.destroy', $ligne));
|
||||
|
||||
$response->assertRedirect();
|
||||
$this->assertDatabaseMissing('ligne_budgets', ['id' => $ligne->id]);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// LigneBudgetController::update — vérification service_id
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function test_responsable_can_update_ligne_of_own_service(): void
|
||||
{
|
||||
$service = Service::factory()->create();
|
||||
$user = $this->userWithRole('responsable', $service);
|
||||
$budget = $this->makeBudget($service);
|
||||
$ligne = $this->makeLigne($budget);
|
||||
|
||||
$response = $this->actingAs($user)->put(route('lignes-budget.update', $ligne), [
|
||||
'nom' => 'Ligne modifiée',
|
||||
'type_depense' => 'fonctionnement',
|
||||
'montant_propose' => 2000,
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$this->assertDatabaseHas('ligne_budgets', ['id' => $ligne->id, 'nom' => 'Ligne modifiée']);
|
||||
}
|
||||
|
||||
public function test_responsable_cannot_update_ligne_of_another_service(): void
|
||||
{
|
||||
$otherService = Service::factory()->create();
|
||||
$myService = Service::factory()->create();
|
||||
$user = $this->userWithRole('responsable', $myService);
|
||||
$budget = $this->makeBudget($otherService);
|
||||
$ligne = $this->makeLigne($budget);
|
||||
|
||||
$response = $this->actingAs($user)->put(route('lignes-budget.update', $ligne), [
|
||||
'nom' => 'Tentative de modification',
|
||||
'type_depense' => 'fonctionnement',
|
||||
'montant_propose' => 9999,
|
||||
]);
|
||||
|
||||
$response->assertForbidden();
|
||||
$this->assertDatabaseHas('ligne_budgets', ['id' => $ligne->id, 'nom' => 'Ligne test']);
|
||||
}
|
||||
|
||||
public function test_cannot_update_ligne_when_budget_is_locked(): void
|
||||
{
|
||||
$service = Service::factory()->create();
|
||||
$user = $this->userWithRole('responsable', $service);
|
||||
$budget = $this->makeBudget($service, 'arbitrage_direction');
|
||||
$ligne = $this->makeLigne($budget);
|
||||
|
||||
$response = $this->actingAs($user)->put(route('lignes-budget.update', $ligne), [
|
||||
'nom' => 'Tentative modif budget verrouillé',
|
||||
'type_depense' => 'investissement',
|
||||
'montant_propose' => 5000,
|
||||
]);
|
||||
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// LigneBudgetController::store — vérification service_id
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function test_responsable_can_add_ligne_to_own_budget(): void
|
||||
{
|
||||
$service = Service::factory()->create();
|
||||
$user = $this->userWithRole('responsable', $service);
|
||||
$budget = $this->makeBudget($service);
|
||||
|
||||
$response = $this->actingAs($user)->post(route('lignes-budget.store'), [
|
||||
'budget_id' => $budget->id,
|
||||
'nom' => 'Nouvelle ligne',
|
||||
'type_depense' => 'investissement',
|
||||
'montant_propose' => 500,
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$this->assertDatabaseHas('ligne_budgets', ['budget_id' => $budget->id, 'nom' => 'Nouvelle ligne']);
|
||||
}
|
||||
|
||||
public function test_responsable_cannot_add_ligne_to_another_service_budget(): void
|
||||
{
|
||||
$otherService = Service::factory()->create();
|
||||
$myService = Service::factory()->create();
|
||||
$user = $this->userWithRole('responsable', $myService);
|
||||
$budget = $this->makeBudget($otherService);
|
||||
|
||||
$response = $this->actingAs($user)->post(route('lignes-budget.store'), [
|
||||
'budget_id' => $budget->id,
|
||||
'nom' => 'Ligne injectée',
|
||||
'type_depense' => 'investissement',
|
||||
'montant_propose' => 500,
|
||||
]);
|
||||
|
||||
$response->assertForbidden();
|
||||
$this->assertDatabaseMissing('ligne_budgets', ['budget_id' => $budget->id]);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// BudgetController::update — statut réservé aux admin/directeur
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function test_directeur_can_change_budget_status(): void
|
||||
{
|
||||
$service = Service::factory()->create();
|
||||
$user = $this->userWithRole('directeur', $service);
|
||||
$budget = $this->makeBudget($service, 'preparation');
|
||||
|
||||
$response = $this->actingAs($user)->put(route('budgets.update', $budget), [
|
||||
'statut' => 'arbitrage_dsi',
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$this->assertDatabaseHas('budgets', ['id' => $budget->id, 'statut' => 'arbitrage_dsi']);
|
||||
}
|
||||
|
||||
public function test_responsable_cannot_change_budget_status(): void
|
||||
{
|
||||
$service = Service::factory()->create();
|
||||
$user = $this->userWithRole('responsable', $service);
|
||||
$budget = $this->makeBudget($service, 'preparation');
|
||||
|
||||
$response = $this->actingAs($user)->put(route('budgets.update', $budget), [
|
||||
'statut' => 'valide',
|
||||
]);
|
||||
|
||||
$response->assertForbidden();
|
||||
$this->assertDatabaseHas('budgets', ['id' => $budget->id, 'statut' => 'preparation']);
|
||||
}
|
||||
|
||||
public function test_acheteur_cannot_change_budget_status(): void
|
||||
{
|
||||
$service = Service::factory()->create();
|
||||
$user = $this->userWithRole('acheteur', $service);
|
||||
$budget = $this->makeBudget($service, 'preparation');
|
||||
|
||||
$response = $this->actingAs($user)->put(route('budgets.update', $budget), [
|
||||
'statut' => 'valide',
|
||||
]);
|
||||
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// BudgetController exports — validation des paramètres enum
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function test_export_excel_ignores_invalid_type_parameter(): void
|
||||
{
|
||||
$admin = $this->userWithRole('admin');
|
||||
|
||||
// Paramètre type invalide — ne doit pas causer d'erreur ni être utilisé
|
||||
$response = $this->actingAs($admin)->get(route('budgets.export.excel', [
|
||||
'annee' => 2026,
|
||||
'type' => 'INJECTED_VALUE; DROP TABLE budgets;',
|
||||
]));
|
||||
|
||||
// Doit retourner le fichier (200) sans planter
|
||||
$response->assertOk();
|
||||
}
|
||||
|
||||
public function test_export_pdf_ignores_invalid_envelope_parameter(): void
|
||||
{
|
||||
$admin = $this->userWithRole('admin');
|
||||
|
||||
$response = $this->actingAs($admin)->get(route('budgets.export.pdf', [
|
||||
'annee' => 2026,
|
||||
'envelope' => 'invalid_value',
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
}
|
||||
|
||||
public function test_export_accepts_valid_type_parameter(): void
|
||||
{
|
||||
$admin = $this->userWithRole('admin');
|
||||
|
||||
$response = $this->actingAs($admin)->get(route('budgets.export.excel', [
|
||||
'annee' => 2026,
|
||||
'type' => 'investissement',
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
}
|
||||
|
||||
public function test_export_accepts_valid_envelope_parameter(): void
|
||||
{
|
||||
$admin = $this->userWithRole('admin');
|
||||
|
||||
$response = $this->actingAs($admin)->get(route('budgets.export.pdf', [
|
||||
'annee' => 2026,
|
||||
'envelope' => 'agglo',
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Arbitrage — réservé aux admin/directeur/raf
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function test_raf_can_arbitrer_ligne(): void
|
||||
{
|
||||
$service = Service::factory()->create();
|
||||
$user = $this->userWithRole('raf', $service);
|
||||
$budget = $this->makeBudget($service);
|
||||
$ligne = $this->makeLigne($budget);
|
||||
|
||||
$response = $this->actingAs($user)->patch(route('lignes-budget.arbitrer', $ligne), [
|
||||
'statut_arbitrage' => 'accepte_dsi',
|
||||
'montant_arbitre' => 800,
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$this->assertDatabaseHas('ligne_budgets', [
|
||||
'id' => $ligne->id,
|
||||
'statut_arbitrage' => 'accepte_dsi',
|
||||
'montant_arbitre' => 800,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_responsable_cannot_arbitrer_ligne(): void
|
||||
{
|
||||
$service = Service::factory()->create();
|
||||
$user = $this->userWithRole('responsable', $service);
|
||||
$budget = $this->makeBudget($service);
|
||||
$ligne = $this->makeLigne($budget);
|
||||
|
||||
$response = $this->actingAs($user)->patch(route('lignes-budget.arbitrer', $ligne), [
|
||||
'statut_arbitrage' => 'valide_definitif',
|
||||
'montant_arbitre' => 1000,
|
||||
]);
|
||||
|
||||
$response->assertForbidden();
|
||||
$this->assertDatabaseHas('ligne_budgets', [
|
||||
'id' => $ligne->id,
|
||||
'statut_arbitrage' => 'brouillon',
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user