gruene-seele/tests/Feature/ProductionHardeningTest.php
Kevin Adametz 9ed47c02f3 Warenwirtschaft: OPT-9 AuthZ/CSRF-Härtung (Audit M-2, M-3, M-6, M-7, M-10)
- M-2: Produktionen buchen Bestand — StoreProductionRequest verlangt
  jetzt admin >= 7 (wie manuelle Bestandsbewegungen); create/edit/copy
  im Controller gegated, UI-Buttons für niedrigere Stufen ausgeblendet.
- M-3: Ausschuss-/Bewegungs-/Produktions-Buttons nur noch für
  Berechtigte sichtbar (keine toten Formulare mehr).
- M-6: Zustandsändernde GET-Routen umgestellt — stock-entries/copy
  jetzt POST mit CSRF; /admin/product/delete jetzt DELETE-Formulare
  (Produktliste + Rezeptur-Zeile im Produktformular).
- M-7: ProductController::imageAttribute whitelistet Spalten
  ('active', 'pos') + numerische Werte, sonst 400 — kein beliebiger
  Spalten-Write per URL mehr.
- M-10: URL-Felder (Lieferant, Verpackungsartikel, INCI-Lieferanten-
  Repeater) validieren url:http,https — keine speicherbaren
  javascript:-Links mehr.

Tests: ProductionHardeningTest +2; drei bestehende Tests an neue
Regeln angepasst (admin-Level 7 für Produktions-Formular, POST-Copy).
Suite: 231 grün.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 12:55:11 +00:00

194 lines
7.3 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use App\Models\Ingredient;
use App\Models\Location;
use App\Models\Product;
use App\Models\ProductIngredient;
use App\Models\Production;
use App\Models\StockDisposal;
use App\Models\StockEntry;
use App\Models\Supplier;
use App\Services\ProductionService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function phUser(): User
{
$user = User::query()->create([
'email' => uniqid('ph_', true).'@test.example',
'password' => bcrypt('password'),
]);
$user->forceFill(['admin' => 7, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false])->save();
return $user->fresh();
}
function phIngredient(string $name): Ingredient
{
return Ingredient::query()->create([
'name' => $name, 'trans_name' => '', 'inci' => $name, 'trans_inci' => '',
'effect' => '', 'trans_effect' => '', 'active' => true, 'pos' => 0,
]);
}
function phStock(Ingredient $ing, Location $location, Supplier $supplier, User $user, array $overrides = []): StockEntry
{
return StockEntry::query()->create(array_merge([
'entry_type' => 'ingredient', 'ingredient_id' => $ing->id,
'supplier_id' => $supplier->id, 'location_id' => $location->id,
'unit' => 'gram', 'ordered_by' => $user->id, 'ordered_at' => '2026-01-01',
'ordered_quantity' => 1000, 'status' => 'received',
'received_by' => $user->id, 'received_at' => '2026-01-15',
'received_quantity' => 1000, 'batch_number' => 'PH-B1', 'best_before' => '2030-12-31',
], $overrides));
}
function phRecipeProduct(Ingredient $ing): Product
{
$product = Product::query()->create(['name' => 'PH-Prod', 'title' => 'PH-Prod', 'active' => true]);
ProductIngredient::create([
'product_id' => $product->id, 'ingredient_id' => $ing->id,
'pos' => 0, 'gram' => 10, 'factor' => 1, 'recipe_type' => 'manufacturer',
]);
return $product;
}
test('negative chargen-menge wird abgelehnt, auch wenn die summe stimmt (H-2)', function () {
$user = phUser();
$this->actingAs($user, 'user');
$ing = phIngredient('PH-Neg');
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$a = phStock($ing, $location, $supplier, $user);
$b = phStock($ing, $location, $supplier, $user, ['batch_number' => 'PH-B2']);
$product = phRecipeProduct($ing);
// Soll = 10 g × 10 Stück = 100 g; +150 und −50 ergäben in Summe 100.
$response = $this->post(route('admin.inventory.productions.store'), [
'product_id' => $product->id,
'location_id' => $location->id,
'produced_at' => '2026-07-15',
'quantity' => 10,
'ingredient_lines' => [
['ingredient_id' => $ing->id, 'stock_entry_id' => $a->id, 'quantity_used' => '150'],
['ingredient_id' => $ing->id, 'stock_entry_id' => $b->id, 'quantity_used' => '-50'],
],
'outputs' => [
['product_id' => $product->id, 'quantity' => 10, 'batch_number' => null],
],
]);
$response->assertSessionHasErrors('ingredient_lines.1.quantity_used');
expect(Production::query()->count())->toBe(0);
});
test('zwei zeilen derselben charge belasten nicht denselben einkauf doppelt (H-3)', function () {
$user = phUser();
$ing = phIngredient('PH-Doppel');
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
// Gleiche Chargennummer, zwei Einkäufe: A = 100 g Rest, B = 200 g Rest.
$a = phStock($ing, $location, $supplier, $user, ['batch_number' => 'PH-X', 'received_quantity' => 100, 'received_at' => '2026-01-10']);
$b = phStock($ing, $location, $supplier, $user, ['batch_number' => 'PH-X', 'received_quantity' => 200, 'received_at' => '2026-01-12']);
$expanded = app(ProductionService::class)->expandBatchLines([
['ingredient_id' => $ing->id, 'batch_key' => 'b:PH-X', 'quantity_used' => 100],
['ingredient_id' => $ing->id, 'batch_key' => 'b:PH-X', 'quantity_used' => 100],
], $location->id);
$byEntry = [];
foreach ($expanded as $line) {
$byEntry[$line['stock_entry_id']] = ($byEntry[$line['stock_entry_id']] ?? 0) + (float) $line['quantity_used'];
}
// Vorher: beide Zeilen landeten auf A (−100 g Überzug). Jetzt: A 100, B 100.
expect($byEntry[$a->id])->toBe(100.0);
expect($byEntry[$b->id])->toBe(100.0);
});
test('ausschuss mit fremder charge wird abgelehnt (M-1)', function () {
$user = phUser();
$this->actingAs($user, 'user');
$ingA = phIngredient('PH-M1-A');
$ingB = phIngredient('PH-M1-B');
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
phStock($ingA, $location, $supplier, $user);
$entryB = phStock($ingB, $location, $supplier, $user, ['batch_number' => 'PH-FREMD']);
$response = $this->post(route('admin.inventory.stock-disposals.store'), [
'disposal_type' => 'ingredient',
'ingredient_id' => $ingA->id,
'stock_entry_id' => $entryB->id,
'location_id' => $location->id,
'quantity' => '100',
'reason' => 'Test',
'disposed_at' => '15.07.2026',
]);
$response->assertSessionHasErrors('stock_entry_id');
expect(StockDisposal::query()->count())->toBe(0);
});
test('ausschuss ueber restbestand wird abgelehnt (M-1)', function () {
$user = phUser();
$this->actingAs($user, 'user');
$ing = phIngredient('PH-M1-Rest');
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
phStock($ing, $location, $supplier, $user); // 1000 g
$response = $this->post(route('admin.inventory.stock-disposals.store'), [
'disposal_type' => 'ingredient',
'ingredient_id' => $ing->id,
'location_id' => $location->id,
'quantity' => '1500',
'reason' => 'Test',
'disposed_at' => '15.07.2026',
]);
$response->assertSessionHasErrors('quantity');
expect(StockDisposal::query()->count())->toBe(0);
});
test('copyreader darf keine produktion buchen (M-2)', function () {
$viewer = User::query()->create([
'email' => uniqid('ph_', true).'@test.example',
'password' => bcrypt('password'),
]);
$viewer->forceFill(['admin' => 1, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false])->save();
$this->actingAs($viewer->fresh(), 'user');
$product = Product::query()->create(['name' => 'PH-M2', 'title' => 'PH-M2', 'active' => true, 'no_recipe_required' => true]);
$location = Location::factory()->create();
$this->post(route('admin.inventory.productions.store'), [
'product_id' => $product->id,
'location_id' => $location->id,
'produced_at' => '2026-07-15',
'quantity' => 5,
'outputs' => [['product_id' => $product->id, 'quantity' => 5, 'batch_number' => null]],
])->assertForbidden();
expect(Production::query()->count())->toBe(0);
});
test('javascript-url wird bei lieferanten abgelehnt (M-10)', function () {
$user = phUser();
$this->actingAs($user, 'user');
$this->post(route('admin.inventory.suppliers.store'), [
'name' => 'PH-XSS-Lieferant',
'url' => 'javascript:alert(1)',
])->assertSessionHasErrors('url');
expect(Supplier::query()->where('name', 'PH-XSS-Lieferant')->exists())->toBeFalse();
});