Admin-Abo: Produkte trotz Mindestlaufzeit abziehen/entfernen

Die Admin-Detailseite postete Warenkorb-Aktionen mit view=me/ot, wodurch
Add-only-Modus und Change-Lock auch fuer Admins griffen (Reduzieren wurde
verworfen, Entfernen lieferte 403). Formular postet jetzt mit view=admin;
checkPermissions laesst view=admin nur noch fuer Admins zu (schliesst
zugleich die Luecke, dass unbekannte Views alle Besitzer-Pruefungen
uebersprangen).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin 2026-07-07 08:17:27 +00:00
parent ce07bee589
commit 9dc138e45b
3 changed files with 208 additions and 3 deletions

View file

@ -145,8 +145,10 @@ class AboController extends Controller
$this->checkPermissions($view, $user_abo);
$editView = \Auth::user()?->isAdmin() ? 'admin' : $view;
// Add-only (Mindestlaufzeit) und Change-Lock richten sich nach der Abo-Sicht ($view),
// NICHT nach Admin-Status – auf der Selbstbedienungs-Seite gelten für alle (auch VIP/Admin)
// die Berater-Regeln. $editView dient nur dem History-Logging.
// NICHT nach Admin-Status – auf der Selbstbedienungs-Seite (view me/ot) gelten für alle
// (auch VIP/Admin) die Berater-Regeln. Die Admin-Detailseite postet mit view=admin
// (nur für Admins zugelassen, siehe checkPermissions) und umgeht beide Sperren.
// $editView dient nur dem History-Logging.
$isAddOnlyMode = AboHelper::isAddOnlyMode($user_abo, $view);
$changeLocked = AboHelper::isAboChangeLocked($user_abo, $view);
@ -753,6 +755,12 @@ class AboController extends Controller
return;
}
// view=admin (und alle unbekannten Views) sind Nicht-Admins nicht erlaubt,
// sonst würden sämtliche is_for-/Besitzer-Prüfungen unten übersprungen.
if (! in_array($view, ['me', 'ot'], true)) {
abort(403, 'Unauthorized action. Invalid view');
}
if ($view === 'me' && $user_abo->is_for !== 'me') {
abort(403, 'Unauthorized action. Is not for me');
}

View file

@ -45,7 +45,7 @@
</div>
{!! Form::open([
'action' => route('user_abos_update', [$view, $user_abo->id]),
'action' => route('user_abos_update', ['admin', $user_abo->id]),
'class' => 'form-horizontal',
'id' => 'cart-order-form',
'data-add-only-mode' => '0',

View file

@ -0,0 +1,197 @@
<?php
use App\Http\Controllers\User\AboController;
use App\Models\Country;
use App\Models\Setting;
use App\Models\Shipping;
use App\Models\ShippingCountry;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Models\UserAboItem;
use App\Models\UserAccount;
use App\Repositories\AboRepository;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Symfony\Component\HttpKernel\Exception\HttpException;
uses(Tests\TestCase::class, RefreshDatabase::class);
/**
* Berater-Abo ('me') innerhalb der Mindestlaufzeit (0 bezahlte Lieferungen, Minimum 6)
* mit zwei Basis-Produkten, außerhalb des Change-Lock-Fensters (next_date weit entfernt).
*
* @return array{abo: UserAbo, consultant: User, itemA: UserAboItem, itemB: UserAboItem}
*/
function makeMinDurationAboFixture(): array
{
Setting::setContentBySlug('abo-min-duration', 6, 'int');
$country = Country::create([
'code' => 'DE', 'phone' => '49', 'en' => 'Germany', 'de' => 'Deutschland',
'es' => 'Alemania', 'fr' => 'Allemagne', 'it' => 'Germania', 'ru' => 'Германия',
]);
$shipping = Shipping::create(['name' => 'Standard', 'active' => true]);
ShippingCountry::create(['shipping_id' => $shipping->id, 'country_id' => $country->id]);
$account = UserAccount::create([
'salutation' => 'Herr',
'first_name' => 'Max',
'last_name' => 'Muster',
'address' => 'Musterstr. 1',
'zipcode' => '12345',
'city' => 'Musterstadt',
'country_id' => $country->id,
'phone' => '123456',
'same_as_billing' => 1,
'language' => 'de',
]);
$consultant = User::forceCreate([
'email' => 'consultant-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'admin' => 0,
'account_id' => $account->id,
]);
$shoppingUser = ShoppingUser::create([
'auth_user_id' => $consultant->id,
'member_id' => $consultant->id,
'billing_country_id' => $country->id,
'shipping_country_id' => $country->id,
'billing_email' => $consultant->email,
'shipping_email' => $consultant->email,
'billing_firstname' => 'Max',
'billing_lastname' => 'Muster',
'shipping_firstname' => 'Max',
'shipping_lastname' => 'Muster',
'same_as_billing' => 1,
'is_for' => 'me',
'is_from' => 'user_order',
]);
$abo = UserAbo::create([
'user_id' => $consultant->id,
'member_id' => $consultant->id,
'shopping_user_id' => $shoppingUser->id,
'is_for' => 'me',
'email' => $consultant->email,
'payone_userid' => random_int(100000, 999999),
'clearingtype' => 'cc',
'active' => true,
'status' => 2,
'abo_interval' => 5,
'next_date' => now()->addDays(30)->toDateString(),
]);
$productA = makeProduct(['12'], 50, 19);
$productB = makeProduct(['12'], 30, 19);
$itemA = UserAboItem::create([
'user_abo_id' => $abo->id, 'product_id' => $productA->id, 'comp' => 0, 'qty' => 3, 'status' => 1,
]);
$itemB = UserAboItem::create([
'user_abo_id' => $abo->id, 'product_id' => $productB->id, 'comp' => 0, 'qty' => 1, 'status' => 1,
]);
return ['abo' => $abo, 'consultant' => $consultant, 'itemA' => $itemA, 'itemB' => $itemB];
}
function makeAboAdminUser(): User
{
return User::forceCreate([
'email' => 'admin-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'admin' => 2,
]);
}
/**
* Simuliert den AJAX-Call der Abo-Detailseite (iq-modal-cart.js) an AboController@update.
*
* @param array<string, mixed> $payload
*/
function callAboCartUpdate(string $view, int $aboId, array $payload): mixed
{
$request = app('request');
$request->headers->set('X-Requested-With', 'XMLHttpRequest');
$request->merge($payload);
return (new AboController(app(AboRepository::class)))->update($view, $aboId);
}
describe('Admin bearbeitet Abo-Produkte trotz Mindestlaufzeit', function () {
it('erlaubt dem Admin das Reduzieren der Menge während der Mindestlaufzeit', function () {
$fixture = makeMinDurationAboFixture();
$this->actingAs(makeAboAdminUser());
$response = callAboCartUpdate('admin', $fixture['abo']->id, [
'action' => 'updateCart',
'product_id' => $fixture['itemA']->product_id,
'order_item_id' => $fixture['itemA']->id,
'cart_order_id' => $fixture['abo']->id,
'qty' => 2,
]);
expect($response->getStatusCode())->toBe(200)
->and($fixture['itemA']->fresh()->qty)->toBe(2);
});
it('erlaubt dem Admin das Entfernen eines Produkts während der Mindestlaufzeit', function () {
$fixture = makeMinDurationAboFixture();
$this->actingAs(makeAboAdminUser());
$response = callAboCartUpdate('admin', $fixture['abo']->id, [
'action' => 'removeFromCart',
'product_id' => $fixture['itemB']->product_id,
'order_item_id' => $fixture['itemB']->id,
'cart_order_id' => $fixture['abo']->id,
]);
expect($response->getStatusCode())->toBe(200)
->and(UserAboItem::find($fixture['itemB']->id))->toBeNull();
});
it('blockiert das Reduzieren für den Berater weiterhin (Add-only, Menge bleibt)', function () {
$fixture = makeMinDurationAboFixture();
$this->actingAs($fixture['consultant']);
callAboCartUpdate('me', $fixture['abo']->id, [
'action' => 'updateCart',
'product_id' => $fixture['itemA']->product_id,
'order_item_id' => $fixture['itemA']->id,
'cart_order_id' => $fixture['abo']->id,
'qty' => 1,
]);
expect($fixture['itemA']->fresh()->qty)->toBe(3);
});
it('blockiert das Entfernen für den Berater weiterhin (Add-only, 403)', function () {
$fixture = makeMinDurationAboFixture();
$this->actingAs($fixture['consultant']);
$response = callAboCartUpdate('me', $fixture['abo']->id, [
'action' => 'removeFromCart',
'product_id' => $fixture['itemB']->product_id,
'order_item_id' => $fixture['itemB']->id,
'cart_order_id' => $fixture['abo']->id,
]);
expect($response->getStatusCode())->toBe(403)
->and(UserAboItem::find($fixture['itemB']->id))->not->toBeNull();
});
it('verbietet Nicht-Admins die Admin-Sicht (view=admin => 403)', function () {
$fixture = makeMinDurationAboFixture();
$this->actingAs($fixture['consultant']);
expect(fn () => callAboCartUpdate('admin', $fixture['abo']->id, [
'action' => 'removeFromCart',
'product_id' => $fixture['itemB']->product_id,
'order_item_id' => $fixture['itemB']->id,
'cart_order_id' => $fixture['abo']->id,
]))->toThrow(HttpException::class);
});
});