feat: VivesPOS landing on Winter CMS 1.2 — theme + plugin + Dockerfile
Some checks are pending
Module sub-split / Sub-split (push) Waiting to run
Some checks are pending
Module sub-split / Sub-split (push) Waiting to run
- Base: wintercms/winter branch 1.2 (full framework) - Theme vivespos: Canvas 7 + Bootstrap 5 CDN, custom CSS - Layout: deferred GTM/GA4 tracking, JSON-LD SoftwareApplication - Partials: hero (offline-first), features, modes (offline/nube toggle), screenshots, pricing (3 planes), comparison, FAQ, CTA - Plugin VivesPOS.Site with ContactForm - Dockerfile: PHP 8.2 Apache, port 80, healthcheck - Added winter/wn-pages, blog, sitemap, seo plugins - Active theme set to vivespos
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace Backend\Tests\Behaviors;
|
||||
|
||||
use Backend\Behaviors\FormController;
|
||||
use System\Tests\Bootstrap\TestCase;
|
||||
|
||||
/**
|
||||
* Unit coverage for FormController record navigation.
|
||||
*
|
||||
* The previous/next/current/total math is a pure, database-agnostic helper
|
||||
* (`resolveRecordPosition`) that resolves a record's position within an already
|
||||
* ordered set of keys. Keeping it free of SQL is what lets the navigation work
|
||||
* identically across every database driver Winter supports — the ordered keys
|
||||
* are read once via a portable `pluck`, and the position is worked out here in
|
||||
* PHP rather than with driver-specific window functions or session variables.
|
||||
*/
|
||||
class FormControllerRecordNavigationTest extends TestCase
|
||||
{
|
||||
public function testEmptySet(): void
|
||||
{
|
||||
$this->assertSame(
|
||||
['previous' => null, 'next' => null, 'current' => null, 'total' => 0],
|
||||
FormController::resolveRecordPosition([], 5)
|
||||
);
|
||||
}
|
||||
|
||||
public function testSingleRecordThatIsCurrent(): void
|
||||
{
|
||||
$this->assertSame(
|
||||
['previous' => null, 'next' => null, 'current' => 1, 'total' => 1],
|
||||
FormController::resolveRecordPosition([5], 5)
|
||||
);
|
||||
}
|
||||
|
||||
public function testFirstRecordHasNextButNoPrevious(): void
|
||||
{
|
||||
$this->assertSame(
|
||||
['previous' => null, 'next' => 6, 'current' => 1, 'total' => 3],
|
||||
FormController::resolveRecordPosition([5, 6, 7], 5)
|
||||
);
|
||||
}
|
||||
|
||||
public function testMiddleRecordHasBothNeighbours(): void
|
||||
{
|
||||
$this->assertSame(
|
||||
['previous' => 5, 'next' => 7, 'current' => 2, 'total' => 3],
|
||||
FormController::resolveRecordPosition([5, 6, 7], 6)
|
||||
);
|
||||
}
|
||||
|
||||
public function testLastRecordHasPreviousButNoNext(): void
|
||||
{
|
||||
$this->assertSame(
|
||||
['previous' => 6, 'next' => null, 'current' => 3, 'total' => 3],
|
||||
FormController::resolveRecordPosition([5, 6, 7], 7)
|
||||
);
|
||||
}
|
||||
|
||||
public function testCurrentKeyNotInSetYieldsNoPosition(): void
|
||||
{
|
||||
$this->assertSame(
|
||||
['previous' => null, 'next' => null, 'current' => null, 'total' => 3],
|
||||
FormController::resolveRecordPosition([5, 6, 7], 99)
|
||||
);
|
||||
}
|
||||
|
||||
public function testKeysAreComparedLoosely(): void
|
||||
{
|
||||
// Database drivers may return keys as strings while the current model
|
||||
// key is an integer (or vice versa); comparison must not be type-strict.
|
||||
$this->assertSame(
|
||||
['previous' => '5', 'next' => '7', 'current' => 2, 'total' => 3],
|
||||
FormController::resolveRecordPosition(['5', '6', '7'], 6)
|
||||
);
|
||||
}
|
||||
|
||||
public function testNonNumericKeysAreSupported(): void
|
||||
{
|
||||
// Works for UUID / string primary keys, not just auto-increment integers.
|
||||
$this->assertSame(
|
||||
['previous' => 'aaa', 'next' => 'ccc', 'current' => 2, 'total' => 3],
|
||||
FormController::resolveRecordPosition(['aaa', 'bbb', 'ccc'], 'bbb')
|
||||
);
|
||||
}
|
||||
|
||||
public function testGapsInKeysArePreserved(): void
|
||||
{
|
||||
$this->assertSame(
|
||||
['previous' => 3, 'next' => 40, 'current' => 3, 'total' => 4],
|
||||
FormController::resolveRecordPosition([1, 3, 12, 40], 12)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
<?php
|
||||
|
||||
namespace Backend\Tests\Behaviors;
|
||||
|
||||
use Backend\Classes\Controller;
|
||||
use Backend\Models\ExportModel;
|
||||
use Backend\Models\ImportModel;
|
||||
use Backend\Tests\Fixtures\Models\UserFixture;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use System\Tests\Bootstrap\PluginTestCase;
|
||||
use Winter\Storm\Exception\ApplicationException;
|
||||
|
||||
/**
|
||||
* Regression coverage for GHSA-fm29-4mq3-phg6.
|
||||
*
|
||||
* `ImportExportController` offers granular per-operation access control through the
|
||||
* `import[permissions]` / `export[permissions]` config keys, enforced by
|
||||
* `userHasAccess()`. That gate was previously applied to the `import()` / `export()`
|
||||
* page actions only. Because `Controller::execAjaxHandlers()` dispatches and returns
|
||||
* before `execPageAction()`, and the behavior binds its widgets in the constructor,
|
||||
* every handler that performed the actual privileged work was reachable without it.
|
||||
*
|
||||
* The gate must therefore be enforced on each handler and action that performs, or
|
||||
* exposes, an import or export.
|
||||
*
|
||||
* @see modules/backend/behaviors/ImportExportController.php
|
||||
*/
|
||||
class GatedExportModel extends ExportModel
|
||||
{
|
||||
public $table = 'backend_users';
|
||||
|
||||
public static array $exportCalls = [];
|
||||
|
||||
public function export($columns, $options = [])
|
||||
{
|
||||
static::$exportCalls[] = $columns;
|
||||
|
||||
return parent::export($columns, $options);
|
||||
}
|
||||
|
||||
public function exportData($columns, $sessionKey = null)
|
||||
{
|
||||
return [
|
||||
['secret' => 'protected-record-1'],
|
||||
['secret' => 'protected-record-2'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class GatedImportModel extends ImportModel
|
||||
{
|
||||
public $table = 'backend_users';
|
||||
public $rules = [];
|
||||
|
||||
public static array $importCalls = [];
|
||||
|
||||
public function import($matches, $options = [])
|
||||
{
|
||||
static::$importCalls[] = $matches;
|
||||
}
|
||||
|
||||
public function importData($results, $sessionKey = null)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares granular permissions finer than its own `$requiredPermissions` -- the only
|
||||
* configuration in which this gate does anything.
|
||||
*/
|
||||
class GatedImportExportController extends Controller
|
||||
{
|
||||
public $implement = [\Backend\Behaviors\ImportExportController::class];
|
||||
|
||||
public $requiredPermissions = ['acme.view_records'];
|
||||
|
||||
public $importExportConfig = [
|
||||
'import' => [
|
||||
'title' => 'Import records',
|
||||
'modelClass' => GatedImportModel::class,
|
||||
'permissions' => ['acme.manage_imports'],
|
||||
'list' => ['columns' => ['secret' => 'Secret']],
|
||||
],
|
||||
'export' => [
|
||||
'title' => 'Export records',
|
||||
'modelClass' => GatedExportModel::class,
|
||||
'permissions' => ['acme.manage_exports'],
|
||||
'list' => ['columns' => ['secret' => 'Secret']],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares no granular permissions. `userHasAccess()` is default-permissive, so this
|
||||
* controller must keep working for any user who can reach it -- the guards must not
|
||||
* become a breaking change for the majority of consumers.
|
||||
*/
|
||||
class UngatedImportExportController extends Controller
|
||||
{
|
||||
public $implement = [\Backend\Behaviors\ImportExportController::class];
|
||||
|
||||
public $requiredPermissions = ['acme.view_records'];
|
||||
|
||||
public $importExportConfig = [
|
||||
'import' => [
|
||||
'title' => 'Import records',
|
||||
'modelClass' => GatedImportModel::class,
|
||||
'list' => ['columns' => ['secret' => 'Secret']],
|
||||
],
|
||||
'export' => [
|
||||
'title' => 'Export records',
|
||||
'modelClass' => GatedExportModel::class,
|
||||
'list' => ['columns' => ['secret' => 'Secret']],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
class ImportExportControllerPermissionsTest extends PluginTestCase
|
||||
{
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
GatedImportModel::$importCalls = [];
|
||||
GatedExportModel::$exportCalls = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Acts as a user holding the coarse controller permission but explicitly denied the
|
||||
* granular import/export permissions -- the attacker in the advisory.
|
||||
*/
|
||||
protected function actAsDeniedUser(): void
|
||||
{
|
||||
$this->actingAs(
|
||||
(new UserFixture)
|
||||
->withPermission('acme.view_records', true)
|
||||
->withPermission('acme.manage_imports', false)
|
||||
->withPermission('acme.manage_exports', false)
|
||||
);
|
||||
}
|
||||
|
||||
protected function actAsPermittedUser(): void
|
||||
{
|
||||
$this->actingAs(
|
||||
(new UserFixture)
|
||||
->withPermission('acme.view_records', true)
|
||||
->withPermission('acme.manage_imports', true)
|
||||
->withPermission('acme.manage_exports', true)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handlers read their input with post(), which returns defaults unless the request
|
||||
* method is genuinely POST -- so a real POST request must be in play.
|
||||
*/
|
||||
protected function makeController(string $class, array $params = []): Controller
|
||||
{
|
||||
$request = \Illuminate\Http\Request::createFromBase(
|
||||
\Symfony\Component\HttpFoundation\Request::create(
|
||||
'http://localhost/backend/acme/records',
|
||||
'POST',
|
||||
$params
|
||||
)
|
||||
);
|
||||
$this->app->instance('request', $request);
|
||||
\Illuminate\Support\Facades\Request::swap($request);
|
||||
|
||||
return new $class;
|
||||
}
|
||||
|
||||
protected function statusOf(callable $fn): ?int
|
||||
{
|
||||
try {
|
||||
$fn();
|
||||
} catch (HttpException $ex) {
|
||||
return $ex->getStatusCode();
|
||||
} catch (\Throwable $ex) {
|
||||
// Anything else means the guard was passed and the method got on with its
|
||||
// work; surface it as "not blocked".
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function guardedCallProvider(): array
|
||||
{
|
||||
return [
|
||||
'onImport' => ['onImport', ['column_match' => [0 => ['file_column' => 'secret', 'db_column' => 'secret']]]],
|
||||
'onImportLoadForm' => ['onImportLoadForm', []],
|
||||
'onImportLoadColumnSampleForm' => ['onImportLoadColumnSampleForm', ['file_column_id' => 0]],
|
||||
'onExport' => ['onExport', ['export_columns' => ['secret'], 'visible_columns' => ['secret' => 1]]],
|
||||
'onExportLoadForm' => ['onExportLoadForm', []],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider guardedCallProvider
|
||||
*/
|
||||
public function testHandlersAreDeniedWithoutTheGranularPermission(string $method, array $params)
|
||||
{
|
||||
$this->actAsDeniedUser();
|
||||
$controller = $this->makeController(GatedImportExportController::class, $params);
|
||||
|
||||
$status = $this->statusOf(fn () => $controller->$method());
|
||||
|
||||
$this->assertSame(403, $status, "{$method}() must abort(403) for a user without the granular permission");
|
||||
}
|
||||
|
||||
public function testDownloadIsDeniedWithoutTheExportPermission()
|
||||
{
|
||||
$this->actAsDeniedUser();
|
||||
$controller = $this->makeController(GatedImportExportController::class);
|
||||
|
||||
$status = $this->statusOf(fn () => $controller->download('oc0000000000000', 'export.csv'));
|
||||
|
||||
$this->assertSame(403, $status, 'download() must abort(403) without the export permission');
|
||||
}
|
||||
|
||||
public function testPageActionsRemainDenied()
|
||||
{
|
||||
$this->actAsDeniedUser();
|
||||
$controller = $this->makeController(GatedImportExportController::class);
|
||||
|
||||
$this->assertSame(403, $this->statusOf(fn () => $controller->import()));
|
||||
$this->assertSame(403, $this->statusOf(fn () => $controller->export()));
|
||||
}
|
||||
|
||||
/**
|
||||
* The point of the fix: the privileged sinks are never reached.
|
||||
*/
|
||||
public function testPrivilegedSinksAreNeverReachedByADeniedUser()
|
||||
{
|
||||
$this->actAsDeniedUser();
|
||||
|
||||
$importer = $this->makeController(GatedImportExportController::class, [
|
||||
'column_match' => [0 => ['file_column' => 'secret', 'db_column' => 'secret']],
|
||||
'first_row_titles' => 1,
|
||||
]);
|
||||
$this->statusOf(fn () => $importer->onImport());
|
||||
|
||||
$exporter = $this->makeController(GatedImportExportController::class, [
|
||||
'export_columns' => ['secret'],
|
||||
'visible_columns' => ['secret' => 1],
|
||||
]);
|
||||
$this->statusOf(fn () => $exporter->onExport());
|
||||
|
||||
$this->assertSame([], GatedImportModel::$importCalls, 'import() sink was reached despite the gate');
|
||||
$this->assertSame([], GatedExportModel::$exportCalls, 'export() sink was reached despite the gate');
|
||||
$this->assertNull($exporter->vars['fileUrl'] ?? null, 'A download reference was produced despite the gate');
|
||||
}
|
||||
|
||||
//
|
||||
// Positive controls -- the guards must not deny users who DO hold the permission
|
||||
//
|
||||
|
||||
public function testPermittedUserCanStillImport()
|
||||
{
|
||||
$this->actAsPermittedUser();
|
||||
$controller = $this->makeController(GatedImportExportController::class, [
|
||||
'column_match' => [0 => ['file_column' => 'secret', 'db_column' => 'secret']],
|
||||
'first_row_titles' => 1,
|
||||
]);
|
||||
|
||||
$controller->onImport();
|
||||
|
||||
$this->assertCount(1, GatedImportModel::$importCalls, 'A permitted user must still be able to import');
|
||||
}
|
||||
|
||||
public function testPermittedUserCanStillExport()
|
||||
{
|
||||
$this->actAsPermittedUser();
|
||||
$controller = $this->makeController(GatedImportExportController::class, [
|
||||
'export_columns' => ['secret'],
|
||||
'visible_columns' => ['secret' => 1],
|
||||
]);
|
||||
|
||||
$controller->onExport();
|
||||
|
||||
$this->assertNotNull($controller->vars['fileUrl'] ?? null, 'A permitted user must still get a download reference');
|
||||
$this->assertCount(1, GatedExportModel::$exportCalls, 'A permitted user must still reach the export sink');
|
||||
}
|
||||
|
||||
public function testPermittedUserPassesTheLoadFormGuards()
|
||||
{
|
||||
$this->actAsPermittedUser();
|
||||
$controller = $this->makeController(GatedImportExportController::class, []);
|
||||
|
||||
$this->assertNull($this->statusOf(fn () => $controller->onImportLoadForm()), 'onImportLoadForm must not 403');
|
||||
$this->assertNull($this->statusOf(fn () => $controller->onExportLoadForm()), 'onExportLoadForm must not 403');
|
||||
}
|
||||
|
||||
/**
|
||||
* onImportLoadColumnSampleForm reaches its own validation once past the guard, which
|
||||
* is proof the guard let it through rather than short-circuiting.
|
||||
*/
|
||||
public function testPermittedUserPassesTheColumnSampleGuard()
|
||||
{
|
||||
$this->actAsPermittedUser();
|
||||
$controller = $this->makeController(GatedImportExportController::class, []);
|
||||
|
||||
$this->expectException(ApplicationException::class);
|
||||
$controller->onImportLoadColumnSampleForm();
|
||||
}
|
||||
|
||||
//
|
||||
// Default-permissive control -- controllers with no granular config are unaffected
|
||||
//
|
||||
|
||||
public function testControllerWithoutGranularPermissionsIsUnaffected()
|
||||
{
|
||||
$this->actAsDeniedUser();
|
||||
|
||||
$controller = $this->makeController(UngatedImportExportController::class, [
|
||||
'column_match' => [0 => ['file_column' => 'secret', 'db_column' => 'secret']],
|
||||
'first_row_titles' => 1,
|
||||
]);
|
||||
|
||||
$this->assertTrue($controller->userHasAccess('import'), 'No config means default-permissive');
|
||||
$this->assertTrue($controller->userHasAccess('export'), 'No config means default-permissive');
|
||||
|
||||
$controller->onImport();
|
||||
|
||||
$this->assertCount(
|
||||
1,
|
||||
GatedImportModel::$importCalls,
|
||||
'Adding the guards must not break controllers that never configured granular permissions'
|
||||
);
|
||||
}
|
||||
}
|
||||
286
modules/backend/tests/behaviors/RelationControllerPivotTest.php
Normal file
286
modules/backend/tests/behaviors/RelationControllerPivotTest.php
Normal file
@@ -0,0 +1,286 @@
|
||||
<?php
|
||||
|
||||
namespace Backend\Tests\Behaviors;
|
||||
|
||||
use Backend\Behaviors\RelationController;
|
||||
use Backend\Models\User;
|
||||
use Backend\Models\UserGroup;
|
||||
use Backend\Tests\Fixtures\Models\PivotRelationFixture;
|
||||
use Backend\Tests\Fixtures\Models\UserFixture;
|
||||
use Db;
|
||||
use System\Tests\Bootstrap\PluginTestCase;
|
||||
use Winter\Storm\Auth\AuthorizationException;
|
||||
use Winter\Storm\Database\Model;
|
||||
|
||||
/**
|
||||
* Regression coverage for wintercms/winter#1464.
|
||||
*
|
||||
* `prepareModelsToSave()` always queues the related model, so a pivot form submission that
|
||||
* contains nothing but pivot data saves the related model too. For a belongsToMany relation
|
||||
* to `Backend\Models\User` that no-op save used to trip the authorization guard in
|
||||
* `User::beforeSave()`, locking operators without `backend.manage_users` out of editing
|
||||
* pivot data entirely.
|
||||
*
|
||||
* The fix moves the guard onto the events that correspond to actual writes — create/update
|
||||
* for attributes, the `model.relation.*` events for relation changes — so the pivot handlers
|
||||
* can save every prepared model unconditionally: a save with nothing to write is authorized
|
||||
* for anyone, while any real change is still guarded at the point it happens.
|
||||
*/
|
||||
class RelationControllerPivotTest extends PluginTestCase
|
||||
{
|
||||
protected const SESSION_KEY = 'pivottestsessionkey';
|
||||
|
||||
protected User $targetUser;
|
||||
protected PivotRelationFixture $fixture;
|
||||
protected RelationController $behavior;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
PivotRelationFixture::migrateUp();
|
||||
|
||||
Model::unguard();
|
||||
$this->targetUser = User::create([
|
||||
'first_name' => 'Target',
|
||||
'last_name' => 'User',
|
||||
'login' => 'targetuser',
|
||||
'email' => 'target@test.com',
|
||||
'password' => 'TestPassword1',
|
||||
'password_confirmation' => 'TestPassword1',
|
||||
'is_activated' => true,
|
||||
'is_superuser' => false,
|
||||
]);
|
||||
Model::reguard();
|
||||
|
||||
$this->fixture = PivotRelationFixture::create(['name' => 'Test Fixture']);
|
||||
$this->fixture->users()->add($this->targetUser, null, ['is_default' => false]);
|
||||
|
||||
$this->behavior = (new \ReflectionClass(RelationController::class))->newInstanceWithoutConstructor();
|
||||
}
|
||||
|
||||
public function tearDown(): void
|
||||
{
|
||||
PivotRelationFixture::migrateDown();
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the related user *through the relation*, exactly as
|
||||
* `onRelationManagePivotUpdate()` does via `$this->pivotWidget->model`.
|
||||
*/
|
||||
protected function getHydratedRelatedUser(): User
|
||||
{
|
||||
return $this->fixture->users()
|
||||
->where('backend_users.id', $this->targetUser->id)
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the production sequence used by both pivot handlers: prepare the models
|
||||
* from the submitted data, then save each one under the pivot session key.
|
||||
*/
|
||||
protected function savePivotForm(User $hydratedModel, array $saveData): void
|
||||
{
|
||||
$modelsToSave = static::callProtectedMethod(
|
||||
$this->behavior,
|
||||
'prepareModelsToSave',
|
||||
[$hydratedModel, $saveData]
|
||||
);
|
||||
|
||||
foreach ($modelsToSave as $modelToSave) {
|
||||
$modelToSave->save(null, static::SESSION_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
protected function getPivotValue()
|
||||
{
|
||||
return Db::table('backend_test_pivot_relation_users')
|
||||
->where('user_id', $this->targetUser->id)
|
||||
->value('is_default');
|
||||
}
|
||||
|
||||
/**
|
||||
* The related model is queued for saving even when the form only submitted pivot data.
|
||||
* This is expected — a save with nothing to write is harmless now that authorization
|
||||
* happens on the actual write events.
|
||||
*/
|
||||
public function testPrepareModelsToSaveQueuesTheRelatedModelForPivotOnlyData(): void
|
||||
{
|
||||
$modelsToSave = static::callProtectedMethod(
|
||||
$this->behavior,
|
||||
'prepareModelsToSave',
|
||||
[$this->getHydratedRelatedUser(), ['pivot' => ['is_default' => true]]]
|
||||
);
|
||||
|
||||
$this->assertContains(User::class, array_map('get_class', $modelsToSave));
|
||||
}
|
||||
|
||||
/**
|
||||
* A pivot-only submission leaves the related model completely unchanged.
|
||||
*/
|
||||
public function testPivotOnlySaveDataLeavesTheRelatedModelClean(): void
|
||||
{
|
||||
$hydrated = $this->getHydratedRelatedUser();
|
||||
|
||||
static::callProtectedMethod(
|
||||
$this->behavior,
|
||||
'prepareModelsToSave',
|
||||
[$hydrated, ['pivot' => ['is_default' => true]]]
|
||||
);
|
||||
|
||||
$this->assertFalse($hydrated->isDirty(), 'The related user has no changed attributes');
|
||||
$this->assertTrue($hydrated->pivot->isDirty(), 'Only the pivot changed');
|
||||
}
|
||||
|
||||
/**
|
||||
* #1464: an operator without `backend.manage_users` can edit pivot data.
|
||||
*/
|
||||
public function testPivotOnlyUpdateSucceedsWithoutManageUsersPermission(): void
|
||||
{
|
||||
$this->actingAs((new UserFixture)->withPermission('backend.manage_users', false));
|
||||
|
||||
$this->savePivotForm($this->getHydratedRelatedUser(), ['pivot' => ['is_default' => true]]);
|
||||
|
||||
$this->assertEquals(1, $this->getPivotValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* The same operation keeps working for an operator who does have the permission.
|
||||
*/
|
||||
public function testPivotOnlyUpdateSucceedsWithManageUsersPermission(): void
|
||||
{
|
||||
$this->actingAs((new UserFixture)->withPermission('backend.manage_users', true));
|
||||
|
||||
$this->savePivotForm($this->getHydratedRelatedUser(), ['pivot' => ['is_default' => true]]);
|
||||
|
||||
$this->assertEquals(1, $this->getPivotValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* A pivot form may also edit fields on the related model. Those changes must still be
|
||||
* written.
|
||||
*/
|
||||
public function testRelatedModelIsStillSavedWhenItsOwnFieldsAreEdited(): void
|
||||
{
|
||||
$this->actingAs((new UserFixture)->withPermission('backend.manage_users', true));
|
||||
|
||||
$this->savePivotForm($this->getHydratedRelatedUser(), [
|
||||
'first_name' => 'Renamed',
|
||||
'pivot' => ['is_default' => true],
|
||||
]);
|
||||
|
||||
$this->assertEquals('Renamed', User::find($this->targetUser->id)->first_name);
|
||||
$this->assertEquals(1, $this->getPivotValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Editing the related model's own fields without the permission is still refused.
|
||||
*/
|
||||
public function testRelatedModelSaveIsStillAuthorizedWhenItsOwnFieldsAreEdited(): void
|
||||
{
|
||||
$this->actingAs((new UserFixture)->withPermission('backend.manage_users', false));
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
|
||||
$this->savePivotForm($this->getHydratedRelatedUser(), [
|
||||
'first_name' => 'Renamed',
|
||||
'pivot' => ['is_default' => true],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A relation field on the related model (saved via `setSimpleValue()`, e.g. a
|
||||
* checkboxlist bound to `groups`) leaves the model's attributes clean and is applied
|
||||
* as a queued sync during the save. The unconditional save must carry it through.
|
||||
*/
|
||||
public function testRelatedModelRelationFieldIsStillApplied(): void
|
||||
{
|
||||
$this->actingAs((new UserFixture)->withPermission('backend.manage_users', true));
|
||||
|
||||
$group = UserGroup::create([
|
||||
'name' => 'Test Group',
|
||||
'code' => 'test-group',
|
||||
]);
|
||||
|
||||
$this->savePivotForm($this->getHydratedRelatedUser(), [
|
||||
'groups' => [$group->id],
|
||||
'pivot' => ['is_default' => true],
|
||||
]);
|
||||
|
||||
$this->assertEquals(1, $this->targetUser->groups()->count(), 'The queued relation sync was applied');
|
||||
$this->assertEquals(1, $this->getPivotValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* The same queued relation sync is refused without the permission: the guard fires on
|
||||
* the relation write itself, even though the model's attributes are clean.
|
||||
*/
|
||||
public function testRelatedModelRelationFieldIsStillAuthorized(): void
|
||||
{
|
||||
$group = UserGroup::create([
|
||||
'name' => 'Test Group',
|
||||
'code' => 'test-group',
|
||||
]);
|
||||
|
||||
$this->actingAs((new UserFixture)->withPermission('backend.manage_users', false));
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
|
||||
$this->savePivotForm($this->getHydratedRelatedUser(), [
|
||||
'groups' => [$group->id],
|
||||
'pivot' => ['is_default' => true],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A deferred binding leaves the owning model clean; committing it during the save must
|
||||
* still work when the operator is authorized.
|
||||
*/
|
||||
public function testDeferredBindingsAreCommittedForOtherwiseCleanModels(): void
|
||||
{
|
||||
$this->actingAs((new UserFixture)->withPermission('backend.manage_users', true));
|
||||
|
||||
$hydrated = $this->getHydratedRelatedUser();
|
||||
|
||||
// Bind a group to the untouched user under the pivot session key
|
||||
$group = UserGroup::create([
|
||||
'name' => 'Test Group',
|
||||
'code' => 'test-group',
|
||||
]);
|
||||
$hydrated->groups()->add($group, static::SESSION_KEY);
|
||||
|
||||
$this->assertFalse($hydrated->isDirty(), 'The deferred binding leaves the user clean');
|
||||
$this->assertEquals(0, $hydrated->groups()->count(), 'Nothing committed yet');
|
||||
|
||||
$this->savePivotForm($hydrated, ['pivot' => ['is_default' => true]]);
|
||||
|
||||
$this->assertEquals(1, $hydrated->groups()->count(), 'The deferred binding was committed');
|
||||
$this->assertEquals(1, $this->getPivotValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Committing a deferred binding is a relation change to another user's record and must
|
||||
* still be refused without the permission, even though the owning model's attributes
|
||||
* are clean.
|
||||
*/
|
||||
public function testDeferredBindingsAreStillAuthorizedForOtherwiseCleanModels(): void
|
||||
{
|
||||
$hydrated = $this->getHydratedRelatedUser();
|
||||
|
||||
$group = UserGroup::create([
|
||||
'name' => 'Test Group',
|
||||
'code' => 'test-group',
|
||||
]);
|
||||
$hydrated->groups()->add($group, static::SESSION_KEY);
|
||||
|
||||
$this->assertFalse($hydrated->isDirty(), 'The deferred binding leaves the user clean');
|
||||
|
||||
$this->actingAs((new UserFixture)->withPermission('backend.manage_users', false));
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
|
||||
$this->savePivotForm($hydrated, ['pivot' => ['is_default' => true]]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user