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>
This commit is contained in:
parent
fa44af0a1f
commit
9ed47c02f3
19 changed files with 104 additions and 41 deletions
|
|
@ -33,8 +33,13 @@ class ProductionController extends Controller
|
|||
]);
|
||||
}
|
||||
|
||||
public function create(Request $request): View
|
||||
public function create(Request $request): View|RedirectResponse
|
||||
{
|
||||
// Audit M-2: Produktionen buchen Bestand — nur admin >= 7.
|
||||
if (! auth()->user()->isAdmin()) {
|
||||
return redirect()->route('home');
|
||||
}
|
||||
|
||||
$defaultLocationId = Location::query()->where('name', 'like', '%öln%')->value('id')
|
||||
?? Location::query()->where('active', true)->first()?->id;
|
||||
|
||||
|
|
@ -101,8 +106,11 @@ class ProductionController extends Controller
|
|||
]);
|
||||
}
|
||||
|
||||
public function edit(Production $production): View
|
||||
public function edit(Production $production): View|RedirectResponse
|
||||
{
|
||||
if (! auth()->user()->isAdmin()) {
|
||||
return redirect()->route('home');
|
||||
}
|
||||
$production->load([
|
||||
'product',
|
||||
'location',
|
||||
|
|
@ -161,8 +169,11 @@ class ProductionController extends Controller
|
|||
return redirect()->route('admin.inventory.productions.show', $production);
|
||||
}
|
||||
|
||||
public function copy(Production $production): View
|
||||
public function copy(Production $production): View|RedirectResponse
|
||||
{
|
||||
if (! auth()->user()->isAdmin()) {
|
||||
return redirect()->route('home');
|
||||
}
|
||||
$production->load([
|
||||
'product',
|
||||
'productionIngredients.ingredient',
|
||||
|
|
|
|||
|
|
@ -222,8 +222,13 @@ class ProductController extends Controller
|
|||
|
||||
public function imageAttribute($product_id, $attr, $val = false)
|
||||
{
|
||||
// Audit M-7: nur erlaubte Spalten — $attr kommt aus der URL und würde
|
||||
// sonst die $fillable-Whitelist per dynamischer Property umgehen.
|
||||
if (! in_array($attr, ['active', 'pos'], true) || ! is_numeric($val)) {
|
||||
abort(400);
|
||||
}
|
||||
|
||||
if (is_numeric($val) && $val < 0) {
|
||||
if ($val < 0) {
|
||||
$val = 0;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ class StorePackagingItemRequest extends FormRequest
|
|||
'category' => ['required', Rule::in(['packaging', 'shipping'])],
|
||||
'weight_grams' => ['nullable', 'numeric', 'min:0'],
|
||||
'min_stock_alert' => ['nullable', 'integer', 'min:0'],
|
||||
'url' => ['nullable', 'string', 'max:2048'],
|
||||
'url' => ['nullable', 'string', 'url:http,https', 'max:2048'],
|
||||
'product_id' => ['nullable', 'integer', 'exists:products,id'],
|
||||
'active' => ['sometimes', 'boolean'],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ class StoreProductionRequest extends FormRequest
|
|||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
// Audit M-2: Produktionen buchen Produktbestand — gleiche Schranke
|
||||
// wie manuelle Bestandsbewegungen (admin >= 7).
|
||||
return (bool) $this->user()?->isAdmin();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -18,10 +18,10 @@ class StoreSupplierRequest extends FormRequest
|
|||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'url' => ['nullable', 'string', 'max:2048'],
|
||||
'url' => ['nullable', 'string', 'url:http,https', 'max:2048'],
|
||||
'order_method' => ['nullable', 'in:email,online_shop'],
|
||||
'order_email' => ['nullable', 'email', 'max:255'],
|
||||
'order_url' => ['nullable', 'string', 'max:2048'],
|
||||
'order_url' => ['nullable', 'string', 'url:http,https', 'max:2048'],
|
||||
'delivery_time' => ['nullable', 'string', 'max:255'],
|
||||
'delivery_time_days' => ['nullable', 'integer', 'min:0', 'max:65535'],
|
||||
'contact_person' => ['nullable', 'string', 'max:255'],
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ class UpdatePackagingItemRequest extends FormRequest
|
|||
'category' => ['required', Rule::in(['packaging', 'shipping'])],
|
||||
'weight_grams' => ['nullable', 'numeric', 'min:0'],
|
||||
'min_stock_alert' => ['nullable', 'integer', 'min:0'],
|
||||
'url' => ['nullable', 'string', 'max:2048'],
|
||||
'url' => ['nullable', 'string', 'url:http,https', 'max:2048'],
|
||||
'product_id' => ['nullable', 'integer', 'exists:products,id'],
|
||||
'active' => ['sometimes', 'boolean'],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -18,10 +18,10 @@ class UpdateSupplierRequest extends FormRequest
|
|||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'url' => ['nullable', 'string', 'max:2048'],
|
||||
'url' => ['nullable', 'string', 'url:http,https', 'max:2048'],
|
||||
'order_method' => ['nullable', 'in:email,online_shop'],
|
||||
'order_email' => ['nullable', 'email', 'max:255'],
|
||||
'order_url' => ['nullable', 'string', 'max:2048'],
|
||||
'order_url' => ['nullable', 'string', 'url:http,https', 'max:2048'],
|
||||
'delivery_time' => ['nullable', 'string', 'max:255'],
|
||||
'delivery_time_days' => ['nullable', 'integer', 'min:0', 'max:65535'],
|
||||
'contact_person' => ['nullable', 'string', 'max:255'],
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ class StoreIngredientRequest extends FormRequest
|
|||
'delivery_time_days' => ['nullable', 'integer', 'min:0', 'max:65535'],
|
||||
'suppliers' => ['nullable', 'array'],
|
||||
'suppliers.*.id' => ['nullable', 'integer', 'exists:suppliers,id'],
|
||||
'suppliers.*.url' => ['nullable', 'string', 'max:2048'],
|
||||
'suppliers.*.url' => ['nullable', 'string', 'url:http,https', 'max:2048'],
|
||||
'storage_room_id' => ['nullable', 'integer', 'exists:storage_rooms,id'],
|
||||
'storage_shelf_id' => ['nullable', 'integer', 'exists:storage_shelves,id'],
|
||||
'storage_slot_id' => ['nullable', 'integer', 'exists:storage_slots,id'],
|
||||
|
|
|
|||
|
|
@ -132,10 +132,10 @@
|
|||
<button type="button" class="btn icon-btn btn-sm btn-outline-danger js-ps-move"
|
||||
data-product="{{ $product->id }}" data-name="{{ $product->name }}" data-direction="out"
|
||||
title="{{ __('Ausgang buchen') }}"><span class="fas fa-minus"></span></button>
|
||||
<a href="{{ route('admin.inventory.productions.create', ['product_id' => $product->id]) }}"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
<span class="fas fa-flask mr-1"></span>{{ __('Produzieren') }}</a>
|
||||
@endif
|
||||
<a href="{{ route('admin.inventory.productions.create', ['product_id' => $product->id]) }}"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
<span class="fas fa-flask mr-1"></span>{{ __('Produzieren') }}</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -10,9 +10,11 @@
|
|||
<p class="wawi-page-head__subtitle">{{ __('Erfasste Produktionschargen inkl. Rohstoff- und Verpackungsverbrauch') }}</p>
|
||||
</div>
|
||||
<div class="wawi-page-head__actions">
|
||||
<a href="{{ route('admin.inventory.productions.create') }}" class="btn btn-primary btn-sm">
|
||||
<span class="fas fa-plus mr-1"></span>{{ __('Neue Produktion') }}
|
||||
</a>
|
||||
@if (Auth::user()->isAdmin())
|
||||
<a href="{{ route('admin.inventory.productions.create') }}" class="btn btn-primary btn-sm">
|
||||
<span class="fas fa-plus mr-1"></span>{{ __('Neue Produktion') }}
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -36,13 +38,13 @@
|
|||
<a href="{{ route('admin.inventory.productions.show', $row) }}" class="btn icon-btn btn-sm btn-primary" title="{{ __('Ansicht') }}">
|
||||
<span class="far fa-eye"></span>
|
||||
</a>
|
||||
<a href="{{ route('admin.inventory.productions.edit', $row) }}" class="btn icon-btn btn-sm btn-secondary" title="{{ __('Bearbeiten') }}">
|
||||
<span class="far fa-edit"></span>
|
||||
</a>
|
||||
<a href="{{ route('admin.inventory.productions.copy', $row) }}" class="btn icon-btn btn-sm btn-outline-secondary" title="{{ __('Kopieren') }}">
|
||||
<span class="far fa-copy"></span>
|
||||
</a>
|
||||
@if(Auth::user()->isAdmin())
|
||||
<a href="{{ route('admin.inventory.productions.edit', $row) }}" class="btn icon-btn btn-sm btn-secondary" title="{{ __('Bearbeiten') }}">
|
||||
<span class="far fa-edit"></span>
|
||||
</a>
|
||||
<a href="{{ route('admin.inventory.productions.copy', $row) }}" class="btn icon-btn btn-sm btn-outline-secondary" title="{{ __('Kopieren') }}">
|
||||
<span class="far fa-copy"></span>
|
||||
</a>
|
||||
<form action="{{ route('admin.inventory.productions.destroy', $row) }}" method="post" class="d-inline"
|
||||
onsubmit="return confirm(@json(__('Produktion wirklich löschen? Der gebuchte Produktbestand wird zurückgebucht und der Rohstoffverbrauch wieder freigegeben.')));">
|
||||
@csrf
|
||||
|
|
|
|||
|
|
@ -46,9 +46,10 @@
|
|||
</a>
|
||||
@endif
|
||||
@if(Auth::user()->isAdmin())
|
||||
<a href="{{ route('admin.inventory.stock-entries.copy', $row) }}" class="btn icon-btn btn-sm btn-info" title="{{ __('Duplizieren') }}">
|
||||
<span class="far fa-copy"></span>
|
||||
</a>
|
||||
<form action="{{ route('admin.inventory.stock-entries.copy', $row) }}" method="post" class="d-inline">
|
||||
@csrf
|
||||
<button type="submit" class="btn icon-btn btn-sm btn-info" title="{{ __('Duplizieren') }}"><span class="far fa-copy"></span></button>
|
||||
</form>
|
||||
@endif
|
||||
</td>
|
||||
<td data-sort="{{ $row->status === 'pending' ? 0 : 1 }}">
|
||||
|
|
|
|||
|
|
@ -22,7 +22,10 @@
|
|||
<div class="wawi-page-head__actions">
|
||||
<a href="{{ route('admin.inventory.stock-entries.index') }}" class="btn btn-sm btn-outline-secondary">{{ __('Zurück zur Liste') }}</a>
|
||||
@if(Auth::user()->isAdmin())
|
||||
<a href="{{ route('admin.inventory.stock-entries.copy', $model) }}" class="btn btn-sm btn-outline-info">{{ __('Duplizieren') }}</a>
|
||||
<form action="{{ route('admin.inventory.stock-entries.copy', $model) }}" method="post" class="d-inline">
|
||||
@csrf
|
||||
<button type="submit" class="btn btn-sm btn-outline-info">{{ __('Duplizieren') }}</button>
|
||||
</form>
|
||||
@endif
|
||||
@if(Auth::user()->isAdmin() && $model->isPending())
|
||||
<a href="{{ route('admin.inventory.stock-entries.edit', $model) }}" class="btn btn-sm btn-primary">{{ __('Bearbeiten') }}</a>
|
||||
|
|
|
|||
|
|
@ -637,10 +637,12 @@
|
|||
<td class="align-middle pi-effective text-right small text-muted">—</td>
|
||||
<td class="align-middle">
|
||||
@if ($product->id)
|
||||
<a class="text-danger"
|
||||
href="{{ route('admin_product_delete', [$product->id, 'ingredient', $ingredient->id]) }}"
|
||||
onclick="return confirm('{{ __('Eintrag entfernen?') }}');"><i
|
||||
class="far fa-trash-alt"></i></a>
|
||||
<form method="post" action="{{ route('admin_product_delete', [$product->id, 'ingredient', $ingredient->id]) }}"
|
||||
class="d-inline" onsubmit="return confirm('{{ __('Eintrag entfernen?') }}');">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="btn btn-link text-danger p-0 border-0 align-baseline"><i class="far fa-trash-alt"></i></button>
|
||||
</form>
|
||||
@else
|
||||
<a class="text-danger ingredient-row-remove" href="#"
|
||||
title="{{ __('Entfernen') }}"><i class="far fa-trash-alt"></i></a>
|
||||
|
|
|
|||
|
|
@ -97,9 +97,12 @@
|
|||
<td><a class="text-info" href="{{ route('admin_product_copy', [$value->id]) }}"
|
||||
onclick="return confirm('{{ __('Eintrag kopieren?') }}');"><i
|
||||
class="far fa-copy"></i></a>
|
||||
<a class="text-danger" href="{{ route('admin_product_delete', [$value->id]) }}"
|
||||
onclick="return confirm('{{ __('Really delete entry?') }}');"><i
|
||||
class="far fa-trash-alt"></i></a>
|
||||
<form method="post" action="{{ route('admin_product_delete', [$value->id]) }}" class="d-inline"
|
||||
onsubmit="return confirm('{{ __('Really delete entry?') }}');">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="btn btn-link text-danger p-0 border-0 align-baseline"><i class="far fa-trash-alt"></i></button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
|
|
|
|||
|
|
@ -323,7 +323,7 @@ Route::domain(config('app.domain'))->group(function () {
|
|||
Route::post('stock-disposals', [InventoryStockDisposalController::class, 'store'])->name('stock-disposals.store');
|
||||
Route::delete('stock-disposals/{stock_disposal}', [InventoryStockDisposalController::class, 'destroy'])->name('stock-disposals.destroy');
|
||||
Route::put('stock-entries/{stock_entry}/receive', [InventoryStockEntryController::class, 'receive'])->name('stock-entries.receive');
|
||||
Route::get('stock-entries/{stock_entry}/copy', [InventoryStockEntryController::class, 'copy'])->name('stock-entries.copy');
|
||||
Route::post('stock-entries/{stock_entry}/copy', [InventoryStockEntryController::class, 'copy'])->name('stock-entries.copy');
|
||||
Route::resource('stock-entries', InventoryStockEntryController::class);
|
||||
Route::resource('productions', InventoryProductionController::class)->only(['index', 'create', 'store', 'show', 'edit', 'update', 'destroy']);
|
||||
Route::get('productions/{production}/copy', [InventoryProductionController::class, 'copy'])->name('productions.copy');
|
||||
|
|
@ -337,7 +337,7 @@ Route::domain(config('app.domain'))->group(function () {
|
|||
Route::post('/admin/product/store', [ProductController::class, 'store'])->name('admin_product_store');
|
||||
Route::get('/admin/product/edit/{id}', [ProductController::class, 'edit'])->name('admin_product_edit');
|
||||
Route::get('/admin/product/copy/{id}', [ProductController::class, 'copy'])->name('admin_product_copy');
|
||||
Route::get('/admin/product/delete/{id}/{do?}/{did?}', [ProductController::class, 'delete'])->name('admin_product_delete');
|
||||
Route::delete('/admin/product/delete/{id}/{do?}/{did?}', [ProductController::class, 'delete'])->name('admin_product_delete');
|
||||
// products images
|
||||
Route::post('/admin/product/image/upload', [ProductController::class, 'imageUpload'])->name('admin_product_image_upload');
|
||||
Route::get('/admin/product/image/delete/{image_id}/{product_id}', [ProductController::class, 'imageDelete'])->name('admin_product_image_delete');
|
||||
|
|
|
|||
|
|
@ -170,7 +170,7 @@ test('produkt-kopie uebernimmt beide rezepturen', function () {
|
|||
test('produktions-formular zeigt nur aktive produkte', function () {
|
||||
p51EnsureCountry();
|
||||
Location::factory()->create(['name' => 'Köln']);
|
||||
$user = p51MakeUser(1);
|
||||
$user = p51MakeUser(7);
|
||||
|
||||
Product::query()->create(['name' => 'P51-AKTIV-PROD', 'title' => 'P51-AKTIV-PROD', 'active' => true]);
|
||||
Product::query()->create(['name' => 'P51-INAKTIV-PROD', 'title' => 'P51-INAKTIV-PROD', 'active' => false]);
|
||||
|
|
|
|||
|
|
@ -176,7 +176,7 @@ test('set kann nicht produziert werden', function () {
|
|||
test('produktions-formular zeigt keine sets', function () {
|
||||
p51EnsureCountryForSet();
|
||||
Location::factory()->create(['name' => 'Köln']);
|
||||
$user = setMakeUser(1);
|
||||
$user = setMakeUser(7);
|
||||
|
||||
setMakeProduct('SET-FORM-EINZEL');
|
||||
$set = setMakeProduct('SET-FORM-SET');
|
||||
|
|
|
|||
|
|
@ -158,3 +158,37 @@ test('ausschuss ueber restbestand wird abgelehnt (M-1)', function () {
|
|||
$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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ test('copy creates pending duplicate without receiving data', function () {
|
|||
|
||||
$this->actingAs($admin, 'user');
|
||||
|
||||
$this->get(route('admin.inventory.stock-entries.copy', $source))
|
||||
$this->post(route('admin.inventory.stock-entries.copy', $source))
|
||||
->assertRedirect();
|
||||
|
||||
$copy = StockEntry::query()->where('status', 'pending')->latest('id')->firstOrFail();
|
||||
|
|
@ -198,6 +198,6 @@ test('copy is blocked for non admin', function () {
|
|||
|
||||
$this->actingAs($copyreader->fresh(), 'user');
|
||||
|
||||
$this->get(route('admin.inventory.stock-entries.copy', $source))
|
||||
$this->post(route('admin.inventory.stock-entries.copy', $source))
|
||||
->assertRedirect(route('home'));
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue