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]]);
|
||||
}
|
||||
}
|
||||
221
modules/backend/tests/classes/AuthManagerTest.php
Normal file
221
modules/backend/tests/classes/AuthManagerTest.php
Normal file
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
namespace Backend\Tests\Classes;
|
||||
|
||||
use System\Tests\Bootstrap\TestCase;
|
||||
use Winter\Storm\Exception\SystemException;
|
||||
use Backend\Classes\AuthManager;
|
||||
|
||||
class AuthManagerTest extends TestCase
|
||||
{
|
||||
protected AuthManager $instance;
|
||||
protected $existingPermissions = [];
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->createApplication();
|
||||
|
||||
$this->instance = AuthManager::instance();
|
||||
|
||||
$this->existingPermissions = $this->instance->listPermissions();
|
||||
|
||||
$this->instance->registerPermissions('Winter.TestCase', [
|
||||
'test.permission_one' => [
|
||||
'label' => 'Test Permission 1',
|
||||
'tab' => 'Test',
|
||||
'order' => 200
|
||||
],
|
||||
'test.permission_two' => [
|
||||
'label' => 'Test Permission 2',
|
||||
'tab' => 'Test',
|
||||
'order' => 300
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
protected function listNewPermissions()
|
||||
{
|
||||
$existing = collect($this->existingPermissions)->pluck('code')->toArray();
|
||||
$allPermissions = collect($this->instance->listPermissions());
|
||||
|
||||
return $allPermissions->whereNotIn('code', $existing)->pluck('code')->toArray();
|
||||
}
|
||||
|
||||
public function tearDown(): void
|
||||
{
|
||||
AuthManager::forgetInstance();
|
||||
}
|
||||
|
||||
public function testListPermissions()
|
||||
{
|
||||
$permissions = $this->listNewPermissions();
|
||||
$this->assertCount(2, $permissions);
|
||||
$this->assertEquals([
|
||||
'test.permission_one',
|
||||
'test.permission_two'
|
||||
], $permissions);
|
||||
}
|
||||
|
||||
public function testRegisterPermissions()
|
||||
{
|
||||
$this->instance->registerPermissions('Winter.TestCase', [
|
||||
'test.permission_three' => [
|
||||
'label' => 'Test Permission 3',
|
||||
'tab' => 'Test',
|
||||
'order' => 100
|
||||
]
|
||||
]);
|
||||
|
||||
$permissions = $this->listNewPermissions();
|
||||
$this->assertCount(3, $permissions);
|
||||
$this->assertEquals([
|
||||
'test.permission_three',
|
||||
'test.permission_one',
|
||||
'test.permission_two'
|
||||
], $permissions);
|
||||
}
|
||||
|
||||
public function testAliasesPermissions()
|
||||
{
|
||||
$this->instance->registerPermissionOwnerAlias('Winter.TestCase', 'Aliased.TestCase');
|
||||
|
||||
$permissions = $this->listNewPermissions();
|
||||
$this->assertCount(2, $permissions);
|
||||
|
||||
$this->instance->removePermission('Aliased.TestCase', 'test.permission_one');
|
||||
|
||||
$permissions = $this->listNewPermissions();
|
||||
$this->assertCount(1, $permissions);
|
||||
$this->assertEquals([
|
||||
'test.permission_two'
|
||||
], $permissions);
|
||||
}
|
||||
|
||||
public function testRegisterPermissionsThroughCallbacks()
|
||||
{
|
||||
// Callback one
|
||||
$this->instance->registerCallback(function ($manager) {
|
||||
$manager->registerPermissions('Winter.TestCase', [
|
||||
'test.permission_three' => [
|
||||
'label' => 'Test Permission 3',
|
||||
'tab' => 'Test',
|
||||
'order' => 100
|
||||
]
|
||||
]);
|
||||
});
|
||||
|
||||
// Callback two
|
||||
$this->instance->registerCallback(function ($manager) {
|
||||
$manager->registerPermissions('Winter.TestCase', [
|
||||
'test.permission_four' => [
|
||||
'label' => 'Test Permission 4',
|
||||
'tab' => 'Test',
|
||||
'order' => 400
|
||||
]
|
||||
]);
|
||||
});
|
||||
|
||||
$permissions = $this->listNewPermissions();
|
||||
$this->assertCount(4, $permissions);
|
||||
$this->assertEquals([
|
||||
'test.permission_three',
|
||||
'test.permission_one',
|
||||
'test.permission_two',
|
||||
'test.permission_four'
|
||||
], $permissions);
|
||||
}
|
||||
|
||||
public function testRegisterAdditionalTab()
|
||||
{
|
||||
$this->instance->registerPermissions('Winter.TestCase', [
|
||||
'test.permission_three' => [
|
||||
'label' => 'Test Permission 3',
|
||||
'tab' => 'Test 2',
|
||||
'order' => 100
|
||||
]
|
||||
]);
|
||||
|
||||
$this->instance->registerCallback(function ($manager) {
|
||||
$manager->registerPermissions('Winter.TestCase', [
|
||||
'test.permission_four' => [
|
||||
'label' => 'Test Permission 4',
|
||||
'tab' => 'Test 2',
|
||||
'order' => 400
|
||||
]
|
||||
]);
|
||||
});
|
||||
|
||||
$tabs = $this->instance->listTabbedPermissions();
|
||||
|
||||
// Remove the core tabs
|
||||
unset($tabs['cms::lang.permissions.name']);
|
||||
unset($tabs['system::lang.permissions.name']);
|
||||
|
||||
$this->assertCount(2, $tabs);
|
||||
$this->assertEquals([
|
||||
'Test 2',
|
||||
'Test'
|
||||
], array_keys($tabs));
|
||||
$this->assertEquals([
|
||||
'test.permission_three',
|
||||
'test.permission_four'
|
||||
], collect($tabs['Test 2'])->pluck('code')->toArray());
|
||||
$this->assertEquals([
|
||||
'test.permission_one',
|
||||
'test.permission_two',
|
||||
], collect($tabs['Test'])->pluck('code')->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* Permissions that let their holder change what other backend users see, or
|
||||
* inject markup that renders for them, must warn whoever grants them. The
|
||||
* permission editor surfaces this through the `comment` key.
|
||||
* See GHSA-5cwr-5jxg-pcf6.
|
||||
*/
|
||||
public function testSecuritySensitivePermissionsHaveComments()
|
||||
{
|
||||
$sensitiveCodes = [
|
||||
'backend.manage_users',
|
||||
'backend.impersonate_users',
|
||||
'backend.manage_editor',
|
||||
'backend.manage_branding',
|
||||
'backend.manage_default_dashboard',
|
||||
'backend.allow_unsafe_markdown',
|
||||
];
|
||||
|
||||
$permissions = collect($this->existingPermissions)->keyBy('code');
|
||||
|
||||
foreach ($sensitiveCodes as $code) {
|
||||
$permission = $permissions->get($code);
|
||||
|
||||
$this->assertNotNull($permission, "Permission $code is not registered");
|
||||
$this->assertNotEmpty($permission->comment, "Permission $code is missing a comment");
|
||||
$this->assertNotEquals(
|
||||
$permission->comment,
|
||||
trans($permission->comment),
|
||||
"Permission $code has an unresolved comment language key"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function testRemovePermission()
|
||||
{
|
||||
$this->instance->removePermission('Winter.TestCase', 'test.permission_one');
|
||||
|
||||
$permissions = $this->listNewPermissions();
|
||||
$this->assertCount(1, $permissions);
|
||||
$this->assertEquals([
|
||||
'test.permission_two'
|
||||
], $permissions);
|
||||
}
|
||||
|
||||
public function testCannotRemovePermissionsBeforeLoaded()
|
||||
{
|
||||
$this->expectException(SystemException::class);
|
||||
$this->expectExceptionMessage('Unable to remove permissions before they are loaded.');
|
||||
|
||||
AuthManager::forgetInstance();
|
||||
$this->instance = AuthManager::instance();
|
||||
$this->instance->removePermission('Winter.TestCase', 'test.permission_one');
|
||||
}
|
||||
}
|
||||
159
modules/backend/tests/classes/ControllerPostbackTest.php
Normal file
159
modules/backend/tests/classes/ControllerPostbackTest.php
Normal file
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace Backend\Tests\Classes;
|
||||
|
||||
use Backend\Controllers\Auth;
|
||||
use Backend\Tests\Fixtures\Models\UserFixture;
|
||||
use Illuminate\Support\Facades\Request;
|
||||
use System\Tests\Bootstrap\PluginTestCase;
|
||||
use Winter\Storm\Exception\SystemException;
|
||||
use Winter\Storm\Support\Facades\Config;
|
||||
|
||||
class ControllerPostbackTest extends PluginTestCase
|
||||
{
|
||||
/**
|
||||
* Builds a mock Request that simulates an AJAX POST with the given handler.
|
||||
*/
|
||||
protected function configAjaxRequestMock(string $handler)
|
||||
{
|
||||
$requestMock = $this
|
||||
->getMockBuilder('Illuminate\Http\Request')
|
||||
->disableOriginalConstructor()
|
||||
->setMethods(['ajax', 'method', 'header', 'secure', 'path', 'getScheme', 'getHost', 'getPort', 'getBaseUrl'])
|
||||
->getMock();
|
||||
|
||||
$map = [
|
||||
['X_WINTER_REQUEST_HANDLER', null, $handler],
|
||||
['X_WINTER_REQUEST_PARTIALS', null, ''],
|
||||
['X-CSRF-TOKEN', null, null],
|
||||
['X-XSRF-TOKEN', null, null],
|
||||
];
|
||||
|
||||
$requestMock->expects($this->any())->method('ajax')->willReturn(true);
|
||||
$requestMock->expects($this->any())->method('method')->willReturn('POST');
|
||||
$requestMock->expects($this->any())->method('header')->willReturnMap($map);
|
||||
$requestMock->expects($this->any())->method('secure')->willReturn(false);
|
||||
$requestMock->expects($this->any())->method('path')->willReturn('backend/auth/signin');
|
||||
$requestMock->expects($this->any())->method('getScheme')->willReturn('http');
|
||||
$requestMock->expects($this->any())->method('getHost')->willReturn('localhost');
|
||||
$requestMock->expects($this->any())->method('getPort')->willReturn(80);
|
||||
$requestMock->expects($this->any())->method('getBaseUrl')->willReturn('');
|
||||
|
||||
return $requestMock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a mock Request that simulates a non-AJAX POST with _handler in POST data.
|
||||
*/
|
||||
protected function configPostbackRequestMock(string $handler)
|
||||
{
|
||||
$requestMock = $this
|
||||
->getMockBuilder('Illuminate\Http\Request')
|
||||
->disableOriginalConstructor()
|
||||
->setMethods(['ajax', 'method', 'header', 'post', 'input', 'secure', 'path', 'getScheme', 'getHost', 'getPort', 'getBaseUrl'])
|
||||
->getMock();
|
||||
|
||||
$requestMock->expects($this->any())->method('ajax')->willReturn(false);
|
||||
$requestMock->expects($this->any())->method('method')->willReturn('POST');
|
||||
$requestMock->expects($this->any())->method('header')->willReturn(null);
|
||||
$requestMock->expects($this->any())->method('secure')->willReturn(false);
|
||||
$requestMock->expects($this->any())->method('path')->willReturn('backend/auth/signin');
|
||||
$requestMock->expects($this->any())->method('getScheme')->willReturn('http');
|
||||
$requestMock->expects($this->any())->method('getHost')->willReturn('localhost');
|
||||
$requestMock->expects($this->any())->method('getPort')->willReturn(80);
|
||||
$requestMock->expects($this->any())->method('getBaseUrl')->willReturn('');
|
||||
|
||||
$postData = ['_handler' => $handler];
|
||||
$requestMock->expects($this->any())->method('post')->willReturnCallback(
|
||||
function ($key = null, $default = null) use ($postData) {
|
||||
return $key === null ? $postData : ($postData[$key] ?? $default);
|
||||
}
|
||||
);
|
||||
$requestMock->expects($this->any())->method('input')->willReturnCallback(
|
||||
function ($key = null, $default = null) use ($postData) {
|
||||
return $key === null ? $postData : ($postData[$key] ?? $default);
|
||||
}
|
||||
);
|
||||
|
||||
return $requestMock;
|
||||
}
|
||||
|
||||
//
|
||||
// AJAX header path — validates handler name (existing behavior)
|
||||
//
|
||||
|
||||
public function testAjaxPathRejectsInvalidHandlerName(): void
|
||||
{
|
||||
$this->actingAs((new UserFixture)->asSuperUser());
|
||||
Config::set('cms.enableCsrfProtection', false);
|
||||
|
||||
// Build controller with real Request, then swap for mock before run()
|
||||
$controller = new Auth;
|
||||
Request::swap($this->configAjaxRequestMock('update_onDelete'));
|
||||
|
||||
$this->expectException(SystemException::class);
|
||||
$this->expectExceptionMessage('Invalid AJAX handler name: update_onDelete.');
|
||||
$controller->run('signin');
|
||||
}
|
||||
|
||||
public function testAjaxPathAcceptsValidHandlerName(): void
|
||||
{
|
||||
$this->actingAs((new UserFixture)->asSuperUser());
|
||||
Config::set('cms.enableCsrfProtection', false);
|
||||
|
||||
$controller = new Auth;
|
||||
Request::swap($this->configAjaxRequestMock('onSave'));
|
||||
|
||||
try {
|
||||
$controller->run('signin');
|
||||
} catch (SystemException $e) {
|
||||
// Handler not found is fine — name validation passed
|
||||
$this->assertStringNotContainsString('Invalid AJAX handler name', $e->getMessage());
|
||||
return;
|
||||
}
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
//
|
||||
// Postback _handler path — validates handler name (our fix)
|
||||
//
|
||||
|
||||
public function testPostbackPathRejectsInvalidHandlerName(): void
|
||||
{
|
||||
$this->actingAs((new UserFixture)->asSuperUser());
|
||||
Config::set('cms.enableCsrfProtection', false);
|
||||
|
||||
$controller = new Auth;
|
||||
Request::swap($this->configPostbackRequestMock('update_onDelete'));
|
||||
|
||||
$this->expectException(SystemException::class);
|
||||
$this->expectExceptionMessage('Invalid AJAX handler name: update_onDelete.');
|
||||
$controller->run('signin');
|
||||
}
|
||||
|
||||
public function testPostbackPathRejectsMethodName(): void
|
||||
{
|
||||
$this->actingAs((new UserFixture)->asSuperUser());
|
||||
Config::set('cms.enableCsrfProtection', false);
|
||||
|
||||
$controller = new Auth;
|
||||
Request::swap($this->configPostbackRequestMock('generatePermissionsField'));
|
||||
|
||||
$this->expectException(SystemException::class);
|
||||
$this->expectExceptionMessage('Invalid AJAX handler name: generatePermissionsField.');
|
||||
$controller->run('signin');
|
||||
}
|
||||
|
||||
public function testPostbackPathRejectsActionPrefixedHandler(): void
|
||||
{
|
||||
$this->actingAs((new UserFixture)->asSuperUser());
|
||||
Config::set('cms.enableCsrfProtection', false);
|
||||
|
||||
$controller = new Auth;
|
||||
Request::swap($this->configPostbackRequestMock('create_onSave'));
|
||||
|
||||
$this->expectException(SystemException::class);
|
||||
$this->expectExceptionMessage('Invalid AJAX handler name: create_onSave.');
|
||||
$controller->run('signin');
|
||||
}
|
||||
}
|
||||
494
modules/backend/tests/classes/HandlerDispatchSecurityTest.php
Normal file
494
modules/backend/tests/classes/HandlerDispatchSecurityTest.php
Normal file
@@ -0,0 +1,494 @@
|
||||
<?php
|
||||
|
||||
namespace Backend\Tests\Classes;
|
||||
|
||||
use Backend\Classes\BackendController;
|
||||
use Backend\Tests\Fixtures\Models\UserFixture;
|
||||
use Cms\Classes\Page as CmsPage;
|
||||
use Cms\Classes\Theme as CmsTheme;
|
||||
use Illuminate\Support\Facades\Request;
|
||||
use System\Models\EventLog;
|
||||
use System\Models\MailLayout;
|
||||
use System\Tests\Bootstrap\PluginTestCase;
|
||||
use Winter\Storm\Support\Facades\Config;
|
||||
use Winter\Storm\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* Regression coverage for GHSA-p2ch-c2c3-4xm5.
|
||||
*
|
||||
* AJAX handlers (`onFoo`, `index_onFoo`) must not be reachable as backend page actions, in any
|
||||
* spelling, while ordinary page actions and AJAX dispatch keep working.
|
||||
*/
|
||||
class HandlerDispatchSecurityTest extends PluginTestCase
|
||||
{
|
||||
protected $canaryPaths = [];
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Config::set('cms.enableCsrfProtection', true);
|
||||
Config::set('cms.backendUri', 'backend');
|
||||
}
|
||||
|
||||
public function tearDown(): void
|
||||
{
|
||||
foreach ($this->canaryPaths as $path) {
|
||||
if (File::exists($path)) {
|
||||
File::delete($path);
|
||||
}
|
||||
}
|
||||
|
||||
EventLog::truncate();
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
//
|
||||
// Helpers
|
||||
//
|
||||
|
||||
protected function parseAction(string $segment): string
|
||||
{
|
||||
$controller = new BackendController();
|
||||
$method = new \ReflectionMethod($controller, 'parseAction');
|
||||
$method->setAccessible(true);
|
||||
|
||||
return $method->invoke($controller, $segment);
|
||||
}
|
||||
|
||||
protected function useTestTheme(): CmsTheme
|
||||
{
|
||||
Config::set('cms.activeTheme', 'test');
|
||||
Config::set('cms.themesPath', '/modules/cms/tests/fixtures/themes');
|
||||
CmsTheme::resetCache();
|
||||
|
||||
return CmsTheme::load('test');
|
||||
}
|
||||
|
||||
protected function seedCanaryPage(CmsTheme $theme, string $fileName): string
|
||||
{
|
||||
$page = CmsPage::inTheme($theme);
|
||||
$page->fileName = $fileName;
|
||||
$page->title = 'CSRF canary';
|
||||
$page->url = '/' . str_replace('.htm', '', $fileName);
|
||||
$page->markup = '<p>canary</p>';
|
||||
$page->save();
|
||||
|
||||
$path = $theme->getPath() . '/pages/' . $fileName;
|
||||
$this->canaryPaths[] = $path;
|
||||
$this->assertTrue(File::exists($path), "Precondition: {$fileName} written to disk");
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
protected function seedEventLog(): void
|
||||
{
|
||||
EventLog::truncate();
|
||||
EventLog::add('csrf canary A');
|
||||
EventLog::add('csrf canary B');
|
||||
}
|
||||
|
||||
protected function canaryCount(): int
|
||||
{
|
||||
return EventLog::where('message', 'like', 'csrf canary%')->count();
|
||||
}
|
||||
|
||||
//
|
||||
// Blocked: handler names must never be reachable as page actions
|
||||
//
|
||||
|
||||
/** The reported primitive, plus its siblings across all three modules. */
|
||||
public function testControllerDeclaredHandlersAreNotReachable()
|
||||
{
|
||||
$cases = [
|
||||
[new \System\Controllers\EventLogs(), 'index_onEmptyLog'],
|
||||
[new \System\Controllers\RequestLogs(), 'index_onEmptyLog'],
|
||||
[new \System\Controllers\MailLayouts(), 'update_onResetDefault'],
|
||||
[new \System\Controllers\MailTemplates(), 'onTest'],
|
||||
[new \System\Controllers\Settings(), 'update_onResetDefault'],
|
||||
[new \Backend\Controllers\Users(), 'update_onUnsuspendUser'],
|
||||
[new \Backend\Controllers\Users(), 'update_onImpersonateUser'],
|
||||
[new \Backend\Controllers\Preferences(), 'index_onResetDefault'],
|
||||
[new \Cms\Controllers\Index(), 'onDelete'],
|
||||
[new \Cms\Controllers\Index(), 'onDeleteTemplates'],
|
||||
[new \Cms\Controllers\Index(), 'onSave'],
|
||||
[new \Cms\Controllers\ThemeOptions(), 'update_onResetDefault'],
|
||||
];
|
||||
|
||||
foreach ($cases as [$controller, $handler]) {
|
||||
$this->assertTrue(
|
||||
$controller->methodExists($handler),
|
||||
get_class($controller) . "::{$handler} must exist for this test to mean anything"
|
||||
);
|
||||
$this->assertFalse(
|
||||
$controller->actionExists($handler),
|
||||
get_class($controller) . "::{$handler} must not be reachable as a page action"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PHP method names are case-insensitive, so both spellings resolve to the same handler --
|
||||
* which is why the guard has to compare the *resolved* name. The lowercase one is the case
|
||||
* that defeats a guard comparing the requested string. Other casings collapse onto these
|
||||
* same two, so they are not repeated.
|
||||
*
|
||||
* The assertTrue() is load-bearing: it proves the spelling really resolves, so that the
|
||||
* assertFalse() beside it cannot pass merely because the method was not found.
|
||||
*/
|
||||
public function testNoCasingOfAHandlerNameIsReachable()
|
||||
{
|
||||
$controller = new \System\Controllers\EventLogs();
|
||||
|
||||
foreach (['index_onEmptyLog', 'index_onemptylog'] as $spelling) {
|
||||
$this->assertTrue(
|
||||
method_exists($controller, $spelling),
|
||||
"Precondition: {$spelling} resolves to the handler"
|
||||
);
|
||||
$this->assertFalse(
|
||||
$controller->actionExists($spelling),
|
||||
"{$spelling} must not be reachable"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* parseAction() lowercases dashed segments, which can turn an arbitrary URL into an
|
||||
* all-lowercase name that still resolves to a mixed-case handler.
|
||||
*/
|
||||
public function testDashedSpellingsCannotLaunderAHandlerName()
|
||||
{
|
||||
$controller = new \System\Controllers\EventLogs();
|
||||
|
||||
// The laundering step is real: this parses to a lowercase name that does resolve.
|
||||
$this->assertEquals('index_onemptylog', $this->parseAction('index-onemptylog'));
|
||||
$this->assertTrue(method_exists($controller, $this->parseAction('index-onemptylog')));
|
||||
|
||||
// One segment per distinct parseAction() result: the laundered name that resolves, and
|
||||
// the two shapes that normalise to something which does not. Extra dash placements all
|
||||
// collapse onto these.
|
||||
foreach (['index-onemptylog', 'index-on-empty-log', 'index_on-emptylog'] as $segment) {
|
||||
$this->assertFalse(
|
||||
$controller->actionExists($this->parseAction($segment)),
|
||||
"Dashed segment '{$segment}' must not reach a handler"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Behaviour handlers. Extension methods are looked up case-sensitively, so only the
|
||||
* canonical spelling resolves at all -- hence the methodExists() assertions below.
|
||||
*/
|
||||
public function testBehaviourProvidedHandlersAreNotReachable()
|
||||
{
|
||||
$users = new \Backend\Controllers\Users();
|
||||
|
||||
// FormController / ListController / RelationController handlers.
|
||||
foreach (['update_onDelete', 'update_onSave', 'create_onSave', 'index_onDelete'] as $handler) {
|
||||
$this->assertTrue($users->methodExists($handler), "Precondition: {$handler} exists");
|
||||
$this->assertFalse($users->actionExists($handler), "{$handler} must not be reachable");
|
||||
}
|
||||
|
||||
// A behaviour that declares no $actions has no allowlist to fall back on, so its
|
||||
// handlers rely entirely on the name guard. Several ecosystem plugins are in this
|
||||
// position, and it is the case most likely to regress.
|
||||
$unguarded = new DispatchProbeBehaviourController();
|
||||
|
||||
$this->assertTrue($unguarded->methodExists('onBar'), 'Precondition: the behaviour supplies it');
|
||||
$this->assertFalse(
|
||||
$unguarded->actionExists('onBar'),
|
||||
'a handler on a behaviour without $actions must still be blocked'
|
||||
);
|
||||
|
||||
foreach (['onbar', 'ONBAR'] as $spelling) {
|
||||
$this->assertFalse(
|
||||
$unguarded->methodExists($spelling),
|
||||
'extension lookup is case-sensitive, so no other casing resolves'
|
||||
);
|
||||
$this->assertFalse($unguarded->actionExists($spelling));
|
||||
}
|
||||
}
|
||||
|
||||
/** Public helpers that were reachable as URLs by accident. */
|
||||
public function testCamelCaseHelpersAreNoLongerRoutable()
|
||||
{
|
||||
$cases = [
|
||||
[new \Backend\Controllers\Files(), 'getThumbUrl'],
|
||||
[new \System\Controllers\Settings(), 'formRender'],
|
||||
[new \System\Controllers\MailBrandSettings(), 'renderSampleMessage'],
|
||||
];
|
||||
|
||||
foreach ($cases as [$controller, $method]) {
|
||||
$this->assertTrue($controller->methodExists($method), "Precondition: {$method} exists");
|
||||
$this->assertFalse($controller->actionExists($method), "{$method} must not be routable");
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Blocked, end to end: token-less GETs must not mutate anything.
|
||||
// One test per way a handler can receive input -- none, a path segment, the query string.
|
||||
//
|
||||
|
||||
public function testTokenlessGetDoesNotTruncateTheEventLog()
|
||||
{
|
||||
$this->seedEventLog();
|
||||
$this->actingAs((new UserFixture)->withPermission('system.access_logs', true));
|
||||
|
||||
foreach (['index_onEmptyLog', 'index_onemptylog'] as $segment) {
|
||||
$status = $this->get("backend/system/eventlogs/{$segment}")->getStatusCode();
|
||||
$this->assertEquals(404, $status, "GET {$segment} must 404");
|
||||
$this->assertEquals(2, $this->canaryCount(), "GET {$segment} must not truncate");
|
||||
}
|
||||
}
|
||||
|
||||
public function testTokenlessGetDoesNotDeleteACmsTemplate()
|
||||
{
|
||||
$theme = $this->useTestTheme();
|
||||
$path = $this->seedCanaryPage($theme, 'csrf-canary.htm');
|
||||
|
||||
$this->actingAs((new UserFixture)->asSuperUser());
|
||||
|
||||
// Cms\Controllers\Index reads its input from Request::input(), which reads the query
|
||||
// string, so the post() helper's method gate does not apply here.
|
||||
$status = $this->get('backend/cms/index/onDelete?' . http_build_query([
|
||||
'theme' => 'test',
|
||||
'templateType' => 'page',
|
||||
'templatePath' => 'csrf-canary.htm',
|
||||
]))->getStatusCode();
|
||||
|
||||
$this->assertEquals(404, $status);
|
||||
$this->assertTrue(File::exists($path), 'A token-less GET must not delete a CMS page');
|
||||
}
|
||||
|
||||
public function testTokenlessGetDoesNotResetAMailLayout()
|
||||
{
|
||||
$layout = MailLayout::first();
|
||||
$this->assertNotNull($layout, 'Precondition: a mail layout exists');
|
||||
|
||||
$layout->content_html = '<p>CUSTOMISED BY OPERATOR</p>';
|
||||
$layout->save();
|
||||
|
||||
$this->actingAs((new UserFixture)->asSuperUser());
|
||||
|
||||
$status = $this->get('backend/system/maillayouts/update_onResetDefault/' . $layout->id)->getStatusCode();
|
||||
|
||||
$this->assertEquals(404, $status);
|
||||
$this->assertEquals(
|
||||
'<p>CUSTOMISED BY OPERATOR</p>',
|
||||
MailLayout::find($layout->id)->content_html,
|
||||
'A token-less GET must not reset a mail layout'
|
||||
);
|
||||
}
|
||||
|
||||
//
|
||||
// Still works: nothing legitimate may regress
|
||||
//
|
||||
|
||||
public function testLowercasePageActionsStillResolve()
|
||||
{
|
||||
$users = new \Backend\Controllers\Users();
|
||||
|
||||
// index comes from ListController, create/update/preview from FormController --
|
||||
// all still exposed through each behaviour's $actions allowlist.
|
||||
foreach (['index', 'create', 'update', 'preview'] as $action) {
|
||||
$this->assertTrue($users->actionExists($action), "Page action {$action} must still resolve");
|
||||
}
|
||||
|
||||
$this->assertTrue((new \Cms\Controllers\Index())->actionExists('index'));
|
||||
$this->assertTrue((new \System\Controllers\EventLogs())->actionExists('index'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts dispatch, not rendering: a 404 would mean a valid page action was rejected,
|
||||
* whereas a 500 is unrelated breakage this test should not be hostage to.
|
||||
*/
|
||||
public function testBackendPagesStillDispatch()
|
||||
{
|
||||
$this->actingAs((new UserFixture)->asSuperUser());
|
||||
|
||||
foreach ([
|
||||
'backend/backend/users',
|
||||
'backend/backend/userroles',
|
||||
'backend/backend/usergroups',
|
||||
'backend/system/eventlogs',
|
||||
'backend/system/settings',
|
||||
'backend/system/maillayouts',
|
||||
'backend/backend/myaccount',
|
||||
'backend/backend/preferences',
|
||||
] as $url) {
|
||||
$this->assertNotEquals(
|
||||
404,
|
||||
$this->get($url)->getStatusCode(),
|
||||
"{$url} must still dispatch -- a 404 means the guard rejected a valid page action"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Handlers must stay callable the way the framework actually calls them. */
|
||||
public function testAjaxHandlerDispatchStillWorks()
|
||||
{
|
||||
Config::set('cms.enableCsrfProtection', false);
|
||||
$this->seedEventLog();
|
||||
$this->actingAs((new UserFixture)->asSuperUser());
|
||||
|
||||
$response = $this->post('backend/system/eventlogs', [], [
|
||||
'X-WINTER-REQUEST-HANDLER' => 'onEmptyLog',
|
||||
'X-Requested-With' => 'XMLHttpRequest',
|
||||
]);
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
$this->assertEquals(0, $this->canaryCount(), 'AJAX dispatch must still reach the handler');
|
||||
}
|
||||
|
||||
/** Dashed URLs keep resolving, now to snake_case rather than camelCase. */
|
||||
public function testDashedUrlsResolveToSnakeCase()
|
||||
{
|
||||
$this->assertEquals('my_action', $this->parseAction('my-action'));
|
||||
$this->assertEquals('index', $this->parseAction('index'));
|
||||
$this->assertEquals('index_onemptylog', $this->parseAction('index-onemptylog'));
|
||||
|
||||
$this->assertTrue((new DispatchProbeSnakeController())->actionExists('coming_soon'));
|
||||
$this->assertTrue((new DispatchProbeFlatController())->actionExists('comingsoon'));
|
||||
$this->assertFalse((new DispatchProbeCamelController())->actionExists('comingSoon'));
|
||||
$this->assertFalse((new DispatchProbeCamelController())->actionExists('comingsoon'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handlers that read post() were always reachable but inert on a GET. They are now
|
||||
* unreachable as well; either way they must not mutate.
|
||||
*/
|
||||
public function testPostGatedHandlersRemainInert()
|
||||
{
|
||||
$this->useTestTheme();
|
||||
$path = themes_path('test');
|
||||
$existedBefore = File::exists($path);
|
||||
|
||||
$this->actingAs((new UserFixture)->asSuperUser());
|
||||
$this->get('backend/cms/themes/index_onDelete?theme=test');
|
||||
|
||||
$this->assertEquals($existedBefore, File::exists($path));
|
||||
}
|
||||
|
||||
//
|
||||
// CMS frontend: structurally immune, pinned so it stays that way
|
||||
//
|
||||
|
||||
/**
|
||||
* The frontend has no page-action dispatch; both handler entry points are POST-gated.
|
||||
* Included here so a change to either gate fails alongside the backend coverage.
|
||||
*/
|
||||
public function testFrontendAjaxHandlerRequiresPost()
|
||||
{
|
||||
$controller = new \Cms\Classes\Controller();
|
||||
|
||||
$headers = [
|
||||
'X-WINTER-REQUEST-HANDLER' => 'onTest',
|
||||
'X-Requested-With' => 'XMLHttpRequest',
|
||||
];
|
||||
|
||||
Request::swap($this->makeRequest('POST', $headers));
|
||||
$this->assertEquals('onTest', $controller->getAjaxHandler(), 'positive control: XHR POST dispatches');
|
||||
|
||||
Request::swap($this->makeRequest('GET', $headers));
|
||||
$this->assertNull($controller->getAjaxHandler(), 'a GET must never yield an AJAX handler');
|
||||
|
||||
Request::swap($this->makeRequest('GET', [], ['_handler' => 'onTest']));
|
||||
$this->assertNull(post('_handler'), 'the _handler postback is unreachable over GET');
|
||||
|
||||
Request::swap($this->makeRequest('POST', [], ['_handler' => 'onTest']));
|
||||
$this->assertEquals('onTest', post('_handler'), 'positive control: POST does supply _handler');
|
||||
}
|
||||
|
||||
protected function makeRequest(string $method, array $headers = [], array $params = [])
|
||||
{
|
||||
$request = \Illuminate\Http\Request::create('/ajax-test', $method, $params);
|
||||
|
||||
foreach ($headers as $key => $value) {
|
||||
$request->headers->set($key, $value);
|
||||
}
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
//
|
||||
// Known residual, pinned deliberately
|
||||
//
|
||||
|
||||
/**
|
||||
* Documents a deliberate boundary: an all-lowercase behaviour method stays routable,
|
||||
* because such a name is indistinguishable from an ordinary page action. Declaring
|
||||
* $actions closes it. Tightening this would break legitimate names like onboarding().
|
||||
*/
|
||||
public function testAllLowercaseBehaviourMethodRemainsRoutable()
|
||||
{
|
||||
$this->assertTrue(
|
||||
(new DispatchProbeBehaviourController())->actionExists('onfoo'),
|
||||
'documents the boundary'
|
||||
);
|
||||
$this->assertFalse(
|
||||
(new DispatchProbeBehaviourController())->actionExists('onBar'),
|
||||
'the conventional spelling is still blocked'
|
||||
);
|
||||
$this->assertFalse(
|
||||
(new DispatchProbeGuardedController())->actionExists('onfoo'),
|
||||
'$actions closes it regardless of casing'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Fixtures. Each casing needs its own class: PHP method names are case-insensitive, so
|
||||
// comingSoon() and comingsoon() cannot coexist in one class.
|
||||
//
|
||||
|
||||
class DispatchProbeCamelController extends \Backend\Classes\Controller
|
||||
{
|
||||
public function comingSoon()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
class DispatchProbeSnakeController extends \Backend\Classes\Controller
|
||||
{
|
||||
public function coming_soon()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
class DispatchProbeFlatController extends \Backend\Classes\Controller
|
||||
{
|
||||
public function comingsoon()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
class DispatchProbeBehaviour extends \Backend\Classes\ControllerBehavior
|
||||
{
|
||||
public function onfoo()
|
||||
{
|
||||
}
|
||||
|
||||
public function onBar()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
class DispatchProbeGuardedBehaviour extends \Backend\Classes\ControllerBehavior
|
||||
{
|
||||
protected $actions = [];
|
||||
|
||||
public function onfoo()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
class DispatchProbeBehaviourController extends \Backend\Classes\Controller
|
||||
{
|
||||
public $implement = [DispatchProbeBehaviour::class];
|
||||
}
|
||||
|
||||
class DispatchProbeGuardedController extends \Backend\Classes\Controller
|
||||
{
|
||||
public $implement = [DispatchProbeGuardedBehaviour::class];
|
||||
}
|
||||
302
modules/backend/tests/classes/NavigationManagerTest.php
Normal file
302
modules/backend/tests/classes/NavigationManagerTest.php
Normal file
@@ -0,0 +1,302 @@
|
||||
<?php
|
||||
|
||||
namespace Backend\Tests\Classes;
|
||||
|
||||
use System\Tests\Bootstrap\TestCase;
|
||||
use Backend\Classes\NavigationManager;
|
||||
|
||||
class NavigationManagerTest extends TestCase
|
||||
{
|
||||
public function testRegisterMenuItems()
|
||||
{
|
||||
$manager = NavigationManager::instance();
|
||||
$items = $manager->listMainMenuItems();
|
||||
$this->assertArrayNotHasKey('WINTER.TEST.DASHBOARD', $items);
|
||||
|
||||
$manager->registerMenuItems('Winter.Test', [
|
||||
'dashboard' => [
|
||||
'label' => 'Dashboard',
|
||||
'icon' => 'icon-dashboard',
|
||||
'url' => 'http://example.com',
|
||||
'order' => 100
|
||||
]
|
||||
]);
|
||||
|
||||
$items = $manager->listMainMenuItems();
|
||||
$this->assertArrayHasKey('WINTER.TEST.DASHBOARD', $items);
|
||||
|
||||
$item = $items['WINTER.TEST.DASHBOARD'];
|
||||
$this->assertObjectHasProperty('code', $item);
|
||||
$this->assertObjectHasProperty('label', $item);
|
||||
$this->assertObjectHasProperty('icon', $item);
|
||||
$this->assertObjectHasProperty('url', $item);
|
||||
$this->assertObjectHasProperty('owner', $item);
|
||||
$this->assertObjectHasProperty('order', $item);
|
||||
$this->assertObjectHasProperty('permissions', $item);
|
||||
$this->assertObjectHasProperty('sideMenu', $item);
|
||||
|
||||
$this->assertEquals('dashboard', $item->code);
|
||||
$this->assertEquals('Dashboard', $item->label);
|
||||
$this->assertEquals('icon-dashboard', $item->icon);
|
||||
$this->assertEquals('http://example.com', $item->url);
|
||||
$this->assertEquals(100, $item->order);
|
||||
$this->assertEquals('Winter.Test', $item->owner);
|
||||
}
|
||||
|
||||
public function testListMainMenuItems()
|
||||
{
|
||||
$manager = NavigationManager::instance();
|
||||
$items = $manager->listMainMenuItems();
|
||||
|
||||
$this->assertArrayHasKey('WINTER.TESTER.BLOG', $items);
|
||||
}
|
||||
|
||||
public function testListSideMenuItems()
|
||||
{
|
||||
$manager = NavigationManager::instance();
|
||||
|
||||
$items = $manager->listSideMenuItems();
|
||||
$this->assertEmpty($items);
|
||||
|
||||
$manager->setContext('Winter.Tester', 'blog');
|
||||
|
||||
$items = $manager->listSideMenuItems();
|
||||
$this->assertIsArray($items);
|
||||
$this->assertArrayHasKey('posts', $items);
|
||||
$this->assertArrayHasKey('categories', $items);
|
||||
|
||||
$this->assertIsObject($items['posts']);
|
||||
$this->assertObjectHasProperty('code', $items['posts']);
|
||||
$this->assertObjectHasProperty('owner', $items['posts']);
|
||||
$this->assertEquals('posts', $items['posts']->code);
|
||||
$this->assertEquals('Winter.Tester', $items['posts']->owner);
|
||||
|
||||
$this->assertObjectHasProperty('permissions', $items['posts']);
|
||||
$this->assertIsArray($items['posts']->permissions);
|
||||
$this->assertCount(1, $items['posts']->permissions);
|
||||
|
||||
$this->assertObjectHasProperty('order', $items['posts']);
|
||||
$this->assertObjectHasProperty('order', $items['categories']);
|
||||
$this->assertEquals(100, $items['posts']->order);
|
||||
$this->assertEquals(200, $items['categories']->order);
|
||||
}
|
||||
|
||||
public function testAddMainMenuItems()
|
||||
{
|
||||
$manager = NavigationManager::instance();
|
||||
$manager->addMainMenuItems('Winter.Tester', [
|
||||
'print' => [
|
||||
'label' => 'Print',
|
||||
'icon' => 'icon-print',
|
||||
'url' => 'javascript:window.print()'
|
||||
]
|
||||
]);
|
||||
|
||||
$items = $manager->listMainMenuItems();
|
||||
|
||||
$this->assertIsArray($items);
|
||||
$this->assertArrayHasKey('WINTER.TESTER.PRINT', $items);
|
||||
|
||||
$item = $items['WINTER.TESTER.PRINT'];
|
||||
$this->assertEquals('print', $item->code);
|
||||
$this->assertEquals('Print', $item->label);
|
||||
$this->assertEquals('icon-print', $item->icon);
|
||||
$this->assertEquals('javascript:window.print()', $item->url);
|
||||
$this->assertEquals(500, $item->order);
|
||||
$this->assertEquals('Winter.Tester', $item->owner);
|
||||
}
|
||||
|
||||
public function testAddMainMenuItemsWithAlias()
|
||||
{
|
||||
$manager = NavigationManager::instance();
|
||||
$manager->addMainMenuItems('Winter.Tester', [
|
||||
'print' => [
|
||||
'label' => 'Print',
|
||||
'icon' => 'icon-print',
|
||||
'url' => 'javascript:window.print()'
|
||||
]
|
||||
]);
|
||||
|
||||
$manager->registerOwnerAlias('Winter.Tester', 'Alias.Tester');
|
||||
|
||||
$item = $manager->getMainMenuItem('Alias.Tester', 'print');
|
||||
|
||||
$this->assertEquals('print', $item->code);
|
||||
$this->assertEquals('Print', $item->label);
|
||||
$this->assertEquals('icon-print', $item->icon);
|
||||
$this->assertEquals('javascript:window.print()', $item->url);
|
||||
$this->assertEquals(500, $item->order);
|
||||
$this->assertEquals('Winter.Tester', $item->owner);
|
||||
}
|
||||
|
||||
public function testRemoveMainMenuItem()
|
||||
{
|
||||
$manager = NavigationManager::instance();
|
||||
$manager->addMainMenuItems('Winter.Tester', [
|
||||
'close' => [
|
||||
'label' => 'Close',
|
||||
'icon' => 'icon-times',
|
||||
'url' => 'javascript:window.close()'
|
||||
]
|
||||
]);
|
||||
|
||||
$items = $manager->listMainMenuItems();
|
||||
$this->assertArrayHasKey('WINTER.TESTER.CLOSE', $items);
|
||||
|
||||
$manager->removeMainMenuItem('Winter.Tester', 'close');
|
||||
|
||||
$items = $manager->listMainMenuItems();
|
||||
$this->assertArrayNotHasKey('WINTER.TESTER.CLOSE', $items);
|
||||
}
|
||||
|
||||
public function testRemoveMainMenuItemByAlias()
|
||||
{
|
||||
$manager = NavigationManager::instance();
|
||||
$manager->addMainMenuItems('Winter.Tester', [
|
||||
'close' => [
|
||||
'label' => 'Close',
|
||||
'icon' => 'icon-times',
|
||||
'url' => 'javascript:window.close()'
|
||||
]
|
||||
]);
|
||||
|
||||
$manager->registerOwnerAlias('Winter.Tester', 'Alias.Tester');
|
||||
|
||||
$items = $manager->listMainMenuItems();
|
||||
$this->assertArrayHasKey('WINTER.TESTER.CLOSE', $items);
|
||||
|
||||
$manager->removeMainMenuItem('Alias.Tester', 'close');
|
||||
|
||||
$items = $manager->listMainMenuItems();
|
||||
$this->assertArrayNotHasKey('WINTER.TESTER.CLOSE', $items);
|
||||
}
|
||||
|
||||
public function testAddSideMenuItems()
|
||||
{
|
||||
$manager = NavigationManager::instance();
|
||||
$manager->listMainMenuItems();
|
||||
|
||||
$manager->addSideMenuItems('Winter.Tester', 'blog', [
|
||||
'foo' => [
|
||||
'label' => 'Bar',
|
||||
'icon' => 'icon-derp',
|
||||
'url' => 'http://google.com',
|
||||
'permissions' => [
|
||||
'winter.tester.access_foo',
|
||||
'winter.tester.access_bar'
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
$manager->setContext('Winter.Tester', 'blog');
|
||||
$items = $manager->listSideMenuItems();
|
||||
|
||||
$this->assertIsArray($items);
|
||||
$this->assertArrayHasKey('foo', $items);
|
||||
|
||||
$this->assertIsObject($items['foo']);
|
||||
$this->assertObjectHasProperty('code', $items['foo']);
|
||||
$this->assertObjectHasProperty('owner', $items['foo']);
|
||||
$this->assertObjectHasProperty('order', $items['foo']);
|
||||
|
||||
$this->assertEquals(-1, $items['foo']->order);
|
||||
$this->assertEquals('foo', $items['foo']->code);
|
||||
$this->assertEquals('Winter.Tester', $items['foo']->owner);
|
||||
|
||||
$this->assertObjectHasProperty('permissions', $items['foo']);
|
||||
$this->assertIsArray($items['foo']->permissions);
|
||||
$this->assertCount(2, $items['foo']->permissions);
|
||||
$this->assertContains('winter.tester.access_foo', $items['foo']->permissions);
|
||||
$this->assertContains('winter.tester.access_bar', $items['foo']->permissions);
|
||||
}
|
||||
|
||||
public function testAddSideMenuItemsWithAlias()
|
||||
{
|
||||
$manager = NavigationManager::instance();
|
||||
$manager->listMainMenuItems();
|
||||
|
||||
$manager->addSideMenuItems('Winter.Tester', 'blog', [
|
||||
'foo' => [
|
||||
'label' => 'Bar',
|
||||
'icon' => 'icon-derp',
|
||||
'url' => 'http://google.com',
|
||||
'permissions' => [
|
||||
'winter.tester.access_foo',
|
||||
'winter.tester.access_bar'
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
$manager->registerOwnerAlias('Winter.Tester', 'Alias.Tester');
|
||||
$manager->setContext('Alias.Tester', 'blog');
|
||||
|
||||
$items = $manager->listSideMenuItems();
|
||||
|
||||
$this->assertTrue(is_array($items));
|
||||
$this->assertArrayHasKey('foo', $items);
|
||||
|
||||
$this->assertTrue(is_object($items['foo']));
|
||||
$this->assertObjectHasProperty('code', $items['foo']);
|
||||
$this->assertObjectHasProperty('owner', $items['foo']);
|
||||
$this->assertObjectHasProperty('order', $items['foo']);
|
||||
|
||||
$this->assertEquals(-1, $items['foo']->order);
|
||||
$this->assertEquals('foo', $items['foo']->code);
|
||||
$this->assertEquals('Winter.Tester', $items['foo']->owner);
|
||||
|
||||
$this->assertObjectHasProperty('permissions', $items['foo']);
|
||||
$this->assertTrue(is_array($items['foo']->permissions));
|
||||
$this->assertCount(2, $items['foo']->permissions);
|
||||
$this->assertContains('winter.tester.access_foo', $items['foo']->permissions);
|
||||
$this->assertContains('winter.tester.access_bar', $items['foo']->permissions);
|
||||
}
|
||||
|
||||
public function testRemoveSideMenuItem()
|
||||
{
|
||||
$manager = NavigationManager::instance();
|
||||
$manager->listMainMenuItems();
|
||||
|
||||
$manager->addSideMenuItems('Winter.Tester', 'blog', [
|
||||
'bar' => [
|
||||
'label' => 'Bar',
|
||||
'icon' => 'icon-bars',
|
||||
'url' => 'http://yahoo.com'
|
||||
]
|
||||
]);
|
||||
|
||||
$manager->setContext('Winter.Tester', 'blog');
|
||||
|
||||
$items = $manager->listSideMenuItems();
|
||||
$this->assertArrayHasKey('bar', $items);
|
||||
|
||||
$manager->removeSideMenuItem('Winter.Tester', 'blog', 'bar');
|
||||
|
||||
$items = $manager->listSideMenuItems();
|
||||
$this->assertArrayNotHasKey('bar', $items);
|
||||
}
|
||||
|
||||
public function testRemoveSideMenuItemByAlias()
|
||||
{
|
||||
$manager = NavigationManager::instance();
|
||||
$manager->listMainMenuItems();
|
||||
|
||||
$manager->addSideMenuItems('Winter.Tester', 'blog', [
|
||||
'bar' => [
|
||||
'label' => 'Bar',
|
||||
'icon' => 'icon-bars',
|
||||
'url' => 'http://yahoo.com'
|
||||
]
|
||||
]);
|
||||
|
||||
$manager->registerOwnerAlias('Winter.Tester', 'Alias.Tester');
|
||||
$manager->setContext('Alias.Tester', 'blog');
|
||||
|
||||
$items = $manager->listSideMenuItems();
|
||||
$this->assertArrayHasKey('bar', $items);
|
||||
|
||||
$manager->removeSideMenuItem('Alias.Tester', 'blog', 'bar');
|
||||
|
||||
$items = $manager->listSideMenuItems();
|
||||
$this->assertArrayNotHasKey('bar', $items);
|
||||
}
|
||||
}
|
||||
49
modules/backend/tests/classes/WidgetManagerTest.php
Normal file
49
modules/backend/tests/classes/WidgetManagerTest.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace Backend\Tests\Classes;
|
||||
|
||||
use System\Tests\Bootstrap\TestCase;
|
||||
use Backend\Classes\WidgetManager;
|
||||
|
||||
class WidgetManagerTest extends TestCase
|
||||
{
|
||||
public function testListFormWidgets()
|
||||
{
|
||||
$manager = WidgetManager::instance();
|
||||
$widgets = $manager->listFormWidgets();
|
||||
|
||||
$this->assertArrayHasKey('TestVendor\Test\FormWidgets\Sample', $widgets);
|
||||
$this->assertArrayHasKey('Winter\Tester\FormWidgets\Preview', $widgets);
|
||||
}
|
||||
|
||||
public function testIfWidgetsCanBeExtended()
|
||||
{
|
||||
$manager = WidgetManager::instance();
|
||||
$manager->registerReportWidget('Acme\Fake\ReportWidget\HelloWorld', [
|
||||
'name' => 'Hello World Test',
|
||||
'context' => 'dashboard'
|
||||
]);
|
||||
$widgets = $manager->listReportWidgets();
|
||||
|
||||
$this->assertArrayHasKey('Acme\Fake\ReportWidget\HelloWorld', $widgets);
|
||||
}
|
||||
|
||||
public function testIfWidgetsCanBeRemoved()
|
||||
{
|
||||
$manager = WidgetManager::instance();
|
||||
$manager->registerReportWidget('Acme\Fake\ReportWidget\HelloWorld', [
|
||||
'name' => 'Hello World Test',
|
||||
'context' => 'dashboard'
|
||||
]);
|
||||
$manager->registerReportWidget('Acme\Fake\ReportWidget\ByeWorld', [
|
||||
'name' => 'Hello World Bye',
|
||||
'context' => 'dashboard'
|
||||
]);
|
||||
|
||||
$manager->removeReportWidget('Acme\Fake\ReportWidget\ByeWorld');
|
||||
|
||||
$widgets = $manager->listReportWidgets();
|
||||
|
||||
$this->assertCount(1, $widgets);
|
||||
}
|
||||
}
|
||||
149
modules/backend/tests/concerns/InteractsWithAuthentication.php
Normal file
149
modules/backend/tests/concerns/InteractsWithAuthentication.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
namespace Backend\Tests\Concerns;
|
||||
|
||||
use Illuminate\Contracts\Auth\Authenticatable as UserContract;
|
||||
|
||||
trait InteractsWithAuthentication
|
||||
{
|
||||
/**
|
||||
* Set the currently logged in user for the application.
|
||||
*
|
||||
* @param \Illuminate\Contracts\Auth\Authenticatable $user
|
||||
* @param string|null $driver
|
||||
* @return $this
|
||||
*/
|
||||
public function actingAs(UserContract $user, $driver = null)
|
||||
{
|
||||
$this->be($user, $driver);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the currently logged in user for the application.
|
||||
*
|
||||
* @param \Illuminate\Contracts\Auth\Authenticatable $user
|
||||
* @param string|null $driver
|
||||
* @return void
|
||||
*/
|
||||
public function be(UserContract $user, $driver = null)
|
||||
{
|
||||
$this->app['backend.auth']->setUser($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the user is authenticated.
|
||||
*
|
||||
* @param string|null $guard
|
||||
* @return $this
|
||||
*/
|
||||
public function assertAuthenticated($guard = null)
|
||||
{
|
||||
$this->assertTrue($this->isAuthenticated($guard), 'The user is not authenticated');
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the user is not authenticated.
|
||||
*
|
||||
* @param string|null $guard
|
||||
* @return $this
|
||||
*/
|
||||
public function assertGuest($guard = null)
|
||||
{
|
||||
$this->assertFalse($this->isAuthenticated($guard), 'The user is authenticated');
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if the user is authenticated, false otherwise.
|
||||
*
|
||||
* @param string|null $guard
|
||||
* @return bool
|
||||
*/
|
||||
protected function isAuthenticated($guard = null)
|
||||
{
|
||||
return $this->app->make('backend.auth')->guard($guard)->check();
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the user is authenticated as the given user.
|
||||
*
|
||||
* @param \Illuminate\Contracts\Auth\Authenticatable $user
|
||||
* @param string|null $guard
|
||||
* @return $this
|
||||
*/
|
||||
public function assertAuthenticatedAs($user, $guard = null)
|
||||
{
|
||||
$expected = $this->app->make('backend.auth')->guard($guard)->user();
|
||||
|
||||
$this->assertNotNull($expected, 'The current user is not authenticated.');
|
||||
|
||||
$this->assertInstanceOf(
|
||||
get_class($expected),
|
||||
$user,
|
||||
'The currently authenticated user is not who was expected'
|
||||
);
|
||||
|
||||
$this->assertSame(
|
||||
$expected->getAuthIdentifier(),
|
||||
$user->getAuthIdentifier(),
|
||||
'The currently authenticated user is not who was expected'
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the given credentials are valid.
|
||||
*
|
||||
* @param array $credentials
|
||||
* @param string|null $guard
|
||||
* @return $this
|
||||
*/
|
||||
public function assertCredentials(array $credentials, $guard = null)
|
||||
{
|
||||
$this->assertTrue(
|
||||
$this->hasCredentials($credentials, $guard),
|
||||
'The given credentials are invalid.'
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the given credentials are invalid.
|
||||
*
|
||||
* @param array $credentials
|
||||
* @param string|null $guard
|
||||
* @return $this
|
||||
*/
|
||||
public function assertInvalidCredentials(array $credentials, $guard = null)
|
||||
{
|
||||
$this->assertFalse(
|
||||
$this->hasCredentials($credentials, $guard),
|
||||
'The given credentials are valid.'
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if the credentials are valid, false otherwise.
|
||||
*
|
||||
* @param array $credentials
|
||||
* @param string|null $guard
|
||||
* @return bool
|
||||
*/
|
||||
protected function hasCredentials(array $credentials, $guard = null)
|
||||
{
|
||||
$provider = $this->app->make('backend.auth')->guard($guard)->getProvider();
|
||||
|
||||
$user = $provider->retrieveByCredentials($credentials);
|
||||
|
||||
return $user && $provider->validateCredentials($user, $credentials);
|
||||
}
|
||||
}
|
||||
33
modules/backend/tests/controllers/AuthResetTest.php
Normal file
33
modules/backend/tests/controllers/AuthResetTest.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Backend\Tests\Controllers;
|
||||
|
||||
use Backend\Controllers\Auth;
|
||||
use Backend\Facades\BackendAuth;
|
||||
use Illuminate\Http\Request as HttpRequest;
|
||||
use Illuminate\Support\Facades\Request;
|
||||
use System\Tests\Bootstrap\PluginTestCase;
|
||||
use Winter\Storm\Exception\ApplicationException;
|
||||
|
||||
class AuthResetTest extends PluginTestCase
|
||||
{
|
||||
/**
|
||||
* A reset submission for a user id that matches nobody must produce the generic reset
|
||||
* error, not a fatal null dereference.
|
||||
*/
|
||||
public function testResetWithUnknownUserIdFailsGracefully(): void
|
||||
{
|
||||
$this->assertNull(BackendAuth::findUserById(999999), 'Expected user id 999999 to be absent');
|
||||
|
||||
Request::swap(HttpRequest::create('/', 'POST', [
|
||||
'id' => 999999,
|
||||
'code' => 'bogus-code',
|
||||
'password' => 'newpassword',
|
||||
]));
|
||||
|
||||
$this->expectException(ApplicationException::class);
|
||||
$this->expectExceptionMessage(trans('backend::lang.account.reset_error'));
|
||||
|
||||
(new Auth)->reset_onSubmit();
|
||||
}
|
||||
}
|
||||
106
modules/backend/tests/controllers/MyAccountSecurityTest.php
Normal file
106
modules/backend/tests/controllers/MyAccountSecurityTest.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace Backend\Tests\Controllers;
|
||||
|
||||
use Backend\Controllers\MyAccount;
|
||||
use Backend\Models\User;
|
||||
use System\Tests\Bootstrap\PluginTestCase;
|
||||
use Winter\Storm\Database\Model;
|
||||
use Winter\Storm\Exception\ApplicationException;
|
||||
use Winter\Storm\Support\Facades\Config;
|
||||
|
||||
/**
|
||||
* Regression coverage for GHSA-mpmw-f6h6-3g26.
|
||||
*
|
||||
* Every authenticated user can reach My Account, so FormController's record-scoped actions --
|
||||
* create, update, preview -- must not be routable here: each takes a caller-supplied key and
|
||||
* would resolve to any backend user. `$guarded` drops them from routing; `formExtendQuery()`
|
||||
* pins the lookup to the current user in case anything reaches the behavior anyway.
|
||||
*/
|
||||
class MyAccountSecurityTest extends PluginTestCase
|
||||
{
|
||||
protected User $mallory;
|
||||
protected User $alice;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Config::set('cms.backendUri', 'backend');
|
||||
Config::set('cms.enableCsrfProtection', false);
|
||||
|
||||
Model::unguard();
|
||||
$this->mallory = $this->makeUser('mallory');
|
||||
$this->alice = $this->makeUser('alice');
|
||||
Model::reguard();
|
||||
|
||||
$this->actingAs($this->mallory);
|
||||
}
|
||||
|
||||
protected function makeUser(string $login): User
|
||||
{
|
||||
return User::create([
|
||||
'first_name' => ucfirst($login),
|
||||
'last_name' => 'User',
|
||||
'login' => $login,
|
||||
'email' => "{$login}@test.test",
|
||||
'password' => 'TestPassword1',
|
||||
'password_confirmation' => 'TestPassword1',
|
||||
'is_activated' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/** The behavior still supplies these for index() to call, but no URL may reach them. */
|
||||
public function testRecordScopedActionsAreNotRoutable(): void
|
||||
{
|
||||
$controller = new MyAccount;
|
||||
|
||||
foreach (['create', 'update', 'preview'] as $action) {
|
||||
$this->assertTrue($controller->methodExists($action), "Precondition: {$action} exists");
|
||||
$this->assertFalse($controller->actionExists($action), "{$action} must not be routable");
|
||||
}
|
||||
|
||||
$this->assertTrue($controller->actionExists('index'), 'index must still route');
|
||||
}
|
||||
|
||||
public function testTheFormLookupIsPinnedToTheCurrentUser(): void
|
||||
{
|
||||
$controller = new MyAccount;
|
||||
|
||||
$this->assertEquals(
|
||||
$this->mallory->getKey(),
|
||||
$controller->formFindModelObject($this->mallory->getKey())->getKey()
|
||||
);
|
||||
|
||||
$this->expectException(ApplicationException::class);
|
||||
$controller->formFindModelObject($this->alice->getKey());
|
||||
}
|
||||
|
||||
/** The reported request. */
|
||||
public function testPreviewOfAnotherUserIsNotServed(): void
|
||||
{
|
||||
$response = $this->get('backend/backend/myaccount/preview/' . $this->alice->getKey());
|
||||
|
||||
$this->assertEquals(404, $response->getStatusCode());
|
||||
$this->assertStringNotContainsString('alice@test.test', $response->getContent());
|
||||
}
|
||||
|
||||
/** Nothing legitimate regressed: routing, the form and the save all still work. */
|
||||
public function testMyAccountStillSavesTheCurrentUsersOwnRecord(): void
|
||||
{
|
||||
$response = $this->post('backend/backend/myaccount', [
|
||||
'User' => [
|
||||
'first_name' => 'Renamed',
|
||||
'last_name' => 'User',
|
||||
'login' => 'mallory',
|
||||
'email' => 'mallory@test.test',
|
||||
],
|
||||
], [
|
||||
'X-WINTER-REQUEST-HANDLER' => 'onSave',
|
||||
'X-Requested-With' => 'XMLHttpRequest',
|
||||
]);
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
$this->assertEquals('Renamed', User::find($this->mallory->getKey())->first_name);
|
||||
}
|
||||
}
|
||||
29
modules/backend/tests/controllers/MyAccountTest.php
Normal file
29
modules/backend/tests/controllers/MyAccountTest.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Backend\Tests\Controllers;
|
||||
|
||||
use Backend\Controllers\MyAccount;
|
||||
use Backend\Controllers\Users;
|
||||
use Backend\Tests\Fixtures\Models\UserFixture;
|
||||
use System\Tests\Bootstrap\PluginTestCase;
|
||||
|
||||
class MyAccountTest extends PluginTestCase
|
||||
{
|
||||
public function testMyAccountRequiresNoSpecificPermission(): void
|
||||
{
|
||||
$user = new UserFixture;
|
||||
$this->actingAs($user);
|
||||
|
||||
$controller = new MyAccount;
|
||||
$this->assertEmpty($controller->requiredPermissions);
|
||||
}
|
||||
|
||||
public function testUsersControllerNoLongerNullifiesPermissionsForMyaccount(): void
|
||||
{
|
||||
$user = (new UserFixture)->withPermission('backend.manage_users', true);
|
||||
$this->actingAs($user);
|
||||
|
||||
$controller = new Users;
|
||||
$this->assertEquals(['backend.manage_users'], $controller->requiredPermissions);
|
||||
}
|
||||
}
|
||||
5
modules/backend/tests/fixtures/assets/compilation.js
vendored
Normal file
5
modules/backend/tests/fixtures/assets/compilation.js
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/* Comments
|
||||
|
||||
=require js/file1.js
|
||||
=require js/file2.js
|
||||
*/
|
||||
1
modules/backend/tests/fixtures/assets/js/file1.js
vendored
Normal file
1
modules/backend/tests/fixtures/assets/js/file1.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
console.log('Test File 1');
|
||||
1
modules/backend/tests/fixtures/assets/js/file2.js
vendored
Normal file
1
modules/backend/tests/fixtures/assets/js/file2.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
console.log('Test File 2');
|
||||
63
modules/backend/tests/fixtures/models/PivotRelationFixture.php
vendored
Normal file
63
modules/backend/tests/fixtures/models/PivotRelationFixture.php
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace Backend\Tests\Fixtures\Models;
|
||||
|
||||
use Backend\Models\User;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Winter\Storm\Database\Model;
|
||||
|
||||
/**
|
||||
* Self-contained model fixture holding a belongsToMany relation to Backend\Models\User
|
||||
* with editable pivot data.
|
||||
*
|
||||
* Owns its own tables so the backend test suite has no dependency on any plugin.
|
||||
*/
|
||||
class PivotRelationFixture extends Model
|
||||
{
|
||||
public $table = 'backend_test_pivot_relation_fixtures';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
public $belongsToMany = [
|
||||
'users' => [
|
||||
User::class,
|
||||
'table' => 'backend_test_pivot_relation_users',
|
||||
'key' => 'fixture_id',
|
||||
'otherKey' => 'user_id',
|
||||
'pivot' => ['is_default'],
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* Create the backing tables if they do not already exist.
|
||||
*/
|
||||
public static function migrateUp(): void
|
||||
{
|
||||
if (!Schema::hasTable('backend_test_pivot_relation_fixtures')) {
|
||||
Schema::create('backend_test_pivot_relation_fixtures', function ($table) {
|
||||
$table->increments('id');
|
||||
$table->string('name')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
if (!Schema::hasTable('backend_test_pivot_relation_users')) {
|
||||
Schema::create('backend_test_pivot_relation_users', function ($table) {
|
||||
$table->integer('fixture_id')->unsigned();
|
||||
$table->integer('user_id')->unsigned();
|
||||
$table->boolean('is_default')->default(false);
|
||||
$table->primary(['fixture_id', 'user_id']);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the backing tables.
|
||||
*/
|
||||
public static function migrateDown(): void
|
||||
{
|
||||
Schema::dropIfExists('backend_test_pivot_relation_users');
|
||||
Schema::dropIfExists('backend_test_pivot_relation_fixtures');
|
||||
}
|
||||
}
|
||||
48
modules/backend/tests/fixtures/models/SortableFixture.php
vendored
Normal file
48
modules/backend/tests/fixtures/models/SortableFixture.php
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace Backend\Tests\Fixtures\Models;
|
||||
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Winter\Storm\Database\Model;
|
||||
use Winter\Storm\Database\Traits\Sortable;
|
||||
|
||||
/**
|
||||
* Self-contained Sortable model fixture for list reordering tests.
|
||||
*
|
||||
* Owns its own table so the backend test suite has no dependency on any plugin.
|
||||
*/
|
||||
class SortableFixture extends Model
|
||||
{
|
||||
use Sortable;
|
||||
|
||||
public $table = 'backend_test_sortable_fixtures';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
/**
|
||||
* Create the backing table if it does not already exist.
|
||||
*/
|
||||
public static function migrateUp(): void
|
||||
{
|
||||
if (Schema::hasTable('backend_test_sortable_fixtures')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::create('backend_test_sortable_fixtures', function ($table) {
|
||||
$table->increments('id');
|
||||
$table->string('name')->nullable();
|
||||
$table->string('label')->nullable();
|
||||
$table->integer('sort_order')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the backing table.
|
||||
*/
|
||||
public static function migrateDown(): void
|
||||
{
|
||||
Schema::dropIfExists('backend_test_sortable_fixtures');
|
||||
}
|
||||
}
|
||||
66
modules/backend/tests/fixtures/models/UserFixture.php
vendored
Normal file
66
modules/backend/tests/fixtures/models/UserFixture.php
vendored
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace Backend\Tests\Fixtures\Models;
|
||||
|
||||
use Backend\Models\User;
|
||||
|
||||
class UserFixture extends User
|
||||
{
|
||||
public function __construct(array $attributes = [])
|
||||
{
|
||||
parent::__construct($attributes);
|
||||
|
||||
$this->fill([
|
||||
'first_name' => 'Test',
|
||||
'last_name' => 'User',
|
||||
'login' => 'testuser',
|
||||
'email' => 'testuser@test.com',
|
||||
'password' => '',
|
||||
'activation_code' => null,
|
||||
'persist_code' => null,
|
||||
'reset_password_code' => null,
|
||||
'permissions' => null,
|
||||
'is_activated' => true,
|
||||
'role_id' => null,
|
||||
'activated_at' => null,
|
||||
'last_login' => '2019-09-27 12:00:00',
|
||||
'created_at' => '2019-09-27 12:00:00',
|
||||
'updated_at' => '2019-09-27 12:00:00',
|
||||
'deleted_at' => null,
|
||||
'is_superuser' => false
|
||||
]);
|
||||
}
|
||||
|
||||
public function asSuperUser()
|
||||
{
|
||||
$this->setAttribute('is_superuser', true);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function asDeletedUser()
|
||||
{
|
||||
$this->setAttribute('deleted_at', date('Y-m-d H:i:s'));
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function withPermission($permission, bool $granted = true)
|
||||
{
|
||||
$currentPermissions = $this->getAttribute('permissions');
|
||||
|
||||
if (is_string($permission)) {
|
||||
$permission = [
|
||||
$permission => (int) $granted
|
||||
];
|
||||
}
|
||||
|
||||
if (is_array($currentPermissions)) {
|
||||
$this->setAttribute('permissions', array_replace($currentPermissions, $permission));
|
||||
} else {
|
||||
$this->setAttribute('permissions', $permission);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
1
modules/backend/tests/fixtures/reference/file1.txt
vendored
Normal file
1
modules/backend/tests/fixtures/reference/file1.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
File one contents
|
||||
1
modules/backend/tests/fixtures/reference/file2.txt
vendored
Normal file
1
modules/backend/tests/fixtures/reference/file2.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
FILE TWO CONTENTS
|
||||
52
modules/backend/tests/formwidgets/CheckboxTest.php
Normal file
52
modules/backend/tests/formwidgets/CheckboxTest.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace Backend\Tests\FormWidgets;
|
||||
|
||||
use Backend\Widgets\Form;
|
||||
use Winter\Storm\Database\Model;
|
||||
use System\Tests\Bootstrap\PluginTestCase;
|
||||
|
||||
class CheckboxTest extends PluginTestCase
|
||||
{
|
||||
public $form = null;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->form = new Form(null, [
|
||||
'model' => new Model,
|
||||
'arrayName' => 'array',
|
||||
'fields' => [
|
||||
'unchecked' => [
|
||||
'type' => 'checkbox',
|
||||
'label' => 'My Test Checkbox unchecked',
|
||||
],
|
||||
'checkedForced' => [
|
||||
'type' => 'checkbox',
|
||||
'label' => 'My Test Checkbox checked',
|
||||
'default' => true,
|
||||
],
|
||||
'uncheckedForced' => [
|
||||
'type' => 'checkbox',
|
||||
'label' => 'My Test Checkbox unchecked',
|
||||
'default' => false,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->form->render();
|
||||
}
|
||||
|
||||
public function testConfigDefaultValue()
|
||||
{
|
||||
$unchecked = $this->form->getField('unchecked');
|
||||
$this->assertFalse($unchecked->isSelected());
|
||||
|
||||
$checkedForced = $this->form->getField('checkedForced');
|
||||
$this->assertTrue($checkedForced->isSelected());
|
||||
|
||||
$uncheckedForced = $this->form->getField('uncheckedForced');
|
||||
$this->assertFalse($uncheckedForced->isSelected());
|
||||
}
|
||||
}
|
||||
128
modules/backend/tests/formwidgets/ColorPickerTest.php
Normal file
128
modules/backend/tests/formwidgets/ColorPickerTest.php
Normal file
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace Backend\Tests\FormWidgets;
|
||||
|
||||
use Backend\Classes\Controller;
|
||||
use Backend\Classes\FormField;
|
||||
use Backend\FormWidgets\ColorPicker;
|
||||
use System\Tests\Bootstrap\PluginTestCase;
|
||||
use Winter\Storm\Exception\ApplicationException;
|
||||
|
||||
class ColorPickerTest extends PluginTestCase
|
||||
{
|
||||
public function testDefaultSaveValue(): void
|
||||
{
|
||||
$widget = $this->makeWidget();
|
||||
|
||||
// Default only expects hex
|
||||
$this->assertEquals('#3498DB', $widget->getSaveValue('#3498DB'));
|
||||
|
||||
// Getting a non-hex value should throw an exception
|
||||
$this->expectException(ApplicationException::class);
|
||||
$widget->getSaveValue('rgba(51.9, 152, 219, 1)');
|
||||
|
||||
// Test a bunch of hex values
|
||||
$this->assertEquals('#3498DB', $widget->getSaveValue('#3498DB'));
|
||||
$this->assertEquals('#2980B9', $widget->getSaveValue('#2980B9'));
|
||||
$this->assertEquals('#9B59B6', $widget->getSaveValue('#9B59B6'));
|
||||
}
|
||||
|
||||
public function testRgbSaveValue(): void
|
||||
{
|
||||
$widget = $this->makeWidget([
|
||||
'formats' => 'rgb'
|
||||
]);
|
||||
|
||||
// Config specifies only rgb
|
||||
$this->assertEquals('rgba(51.9, 152, 219, 1)', $widget->getSaveValue('rgba(51.9, 152, 219, 1)'));
|
||||
|
||||
// Getting a non-rgb value should throw an exception
|
||||
$this->expectException(ApplicationException::class);
|
||||
$widget->getSaveValue('#3498DB');
|
||||
|
||||
// Test a bunch of rgb values
|
||||
$this->assertEquals('rgba(1, 1, 1, 1)', $widget->getSaveValue('rgba(1, 1, 1, 1)'));
|
||||
$this->assertEquals('rgba(155, 89, 182, 0.5)', $widget->getSaveValue('rgba(155, 89, 182, 0.5)'));
|
||||
$this->assertEquals('rgba(1, 89, 182, 0.55)', $widget->getSaveValue('rgba(1, 89, 182, 0.55)'));
|
||||
}
|
||||
|
||||
public function testCmykSaveValue(): void
|
||||
{
|
||||
$widget = $this->makeWidget([
|
||||
'formats' => 'cmyk'
|
||||
]);
|
||||
|
||||
// Config specifies only cmyk
|
||||
$this->assertEquals('cmyk(76.3%, 30.6%, 0%, 14.1%)', $widget->getSaveValue('cmyk(76.3%, 30.6%, 0%, 14.1%)'));
|
||||
|
||||
// Getting a non-cmyk value should throw an exception
|
||||
$this->expectException(ApplicationException::class);
|
||||
$widget->getSaveValue('#3498DB');
|
||||
|
||||
// Test a bunch of cmyk values
|
||||
$this->assertEquals('cmyk(14.8%, 51.1%, 0%, 28.6%)', $widget->getSaveValue('cmyk(14.8%, 51.1%, 0%, 28.6%)'));
|
||||
$this->assertEquals('cmyk(17%, 60.75%, 0%, 32.22%)', $widget->getSaveValue('cmyk(17%, 60.75%, 0%, 32.22%)'));
|
||||
$this->assertEquals('cmyk(17.9%, 60.75%, 0%, 32.2%)', $widget->getSaveValue('cmyk(17.9%, 60.75%, 0%, 32.2%)'));
|
||||
}
|
||||
|
||||
public function testHslaSaveValue(): void
|
||||
{
|
||||
$widget = $this->makeWidget([
|
||||
'formats' => 'hsl'
|
||||
]);
|
||||
|
||||
// Config specifies only hsl
|
||||
$this->assertEquals('hsla(204.1, 69.9%, 53.1%, 1)', $widget->getSaveValue('hsla(204.1, 69.9%, 53.1%, 1)'));
|
||||
|
||||
// Getting a non-hsl value should throw an exception
|
||||
$this->expectException(ApplicationException::class);
|
||||
$widget->getSaveValue('#3498DB');
|
||||
|
||||
// Test a bunch of hsl values
|
||||
$this->assertEquals('hsla(282.3, 43.6%, 47.2%, 1)', $widget->getSaveValue('hsla(282.3, 43.6%, 47.2%, 1)'));
|
||||
$this->assertEquals('hsla(282.3, 43.6%, 47.2%, 0.1)', $widget->getSaveValue('hsla(282.3, 43.6%, 47.2%, 0.1)'));
|
||||
$this->assertEquals('hsla(282, 43.6%, 47.2%, 0.1)', $widget->getSaveValue('hsla(282, 43.6%, 47.2%, 0.1)'));
|
||||
$this->assertEquals('hsla(282, 43.56%, 47.2%, 0.1)', $widget->getSaveValue('hsla(282, 43.56%, 47.2%, 0.1)'));
|
||||
$this->assertEquals('hsla(282.22, 43%, 47.2%, 0.1)', $widget->getSaveValue('hsla(282.22, 43%, 47.2%, 0.1)'));
|
||||
}
|
||||
|
||||
public function testAllSaveValue(): void
|
||||
{
|
||||
$widget = $this->makeWidget([
|
||||
'formats' => 'all'
|
||||
]);
|
||||
|
||||
// Config allows for any valid format
|
||||
$this->assertEquals('#3498DB', $widget->getSaveValue('#3498DB'));
|
||||
$this->assertEquals('rgba(51.9, 152, 219, 1)', $widget->getSaveValue('rgba(51.9, 152, 219, 1)'));
|
||||
$this->assertEquals('cmyk(76.3%, 30.6%, 0%, 14.1%)', $widget->getSaveValue('cmyk(76.3%, 30.6%, 0%, 14.1%)'));
|
||||
$this->assertEquals('hsla(204.1, 69.9%, 53.1%, 1)', $widget->getSaveValue('hsla(204.1, 69.9%, 53.1%, 1)'));
|
||||
|
||||
// Getting a invalid value should throw an exception
|
||||
$this->expectException(ApplicationException::class);
|
||||
$widget->getSaveValue('#Winter Is Awesome');
|
||||
|
||||
$this->expectException(ApplicationException::class);
|
||||
$widget->getSaveValue('rgba(51.9, 152, 219, 1) -- test');
|
||||
|
||||
$this->expectException(ApplicationException::class);
|
||||
$widget->getSaveValue('Test(51.9, 152, 219, 1)');
|
||||
}
|
||||
|
||||
public function testAllowCustomSaveValue(): void
|
||||
{
|
||||
$widget = $this->makeWidget([
|
||||
'formats' => 'custom'
|
||||
]);
|
||||
|
||||
// Config allows for any format
|
||||
$this->assertEquals('rgba(51.9, 152, 219, 1)', $widget->getSaveValue('rgba(51.9, 152, 219, 1)'));
|
||||
$this->assertEquals('#Winter Is Awesome', $widget->getSaveValue('#Winter Is Awesome'));
|
||||
$this->assertEquals('Test(51.9, 152, 219, 1)', $widget->getSaveValue('Test(51.9, 152, 219, 1)'));
|
||||
}
|
||||
|
||||
protected function makeWidget(array $config = []): ColorPicker
|
||||
{
|
||||
return new ColorPicker(new Controller(), new FormField('test', 'Test'), $config);
|
||||
}
|
||||
}
|
||||
255
modules/backend/tests/formwidgets/FileUploadScopingTest.php
Normal file
255
modules/backend/tests/formwidgets/FileUploadScopingTest.php
Normal file
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
|
||||
namespace Backend\Tests\FormWidgets;
|
||||
|
||||
use Backend\Classes\Controller;
|
||||
use Backend\Classes\FormField;
|
||||
use Backend\FormWidgets\FileUpload;
|
||||
use Database\Tester\Models\User as TesterUser;
|
||||
use System\Models\File as FileModel;
|
||||
use System\Tests\Bootstrap\PluginTestCase;
|
||||
use Winter\Storm\Database\Model;
|
||||
use Winter\Storm\Exception\ApplicationException;
|
||||
|
||||
/**
|
||||
* Ensures FileUpload::getFileRecord() only ever resolves a posted `file_id` that
|
||||
* belongs to the widget's own relation (including the current deferred-binding
|
||||
* session), and never an arbitrary System\Models\File row from another record.
|
||||
*
|
||||
* Regression test for GHSA-3277-h8g9-qj5f (IDOR via attacker-controlled file_id).
|
||||
*/
|
||||
class FileUploadScopingTest extends PluginTestCase
|
||||
{
|
||||
protected string $imagePath;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->imagePath = base_path(
|
||||
'modules/system/tests/fixtures/plugins/database/tester/assets/images/avatar.png'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The legitimate case: a file already attached to this record (parent saved)
|
||||
* must still resolve, otherwise editing existing attachments breaks.
|
||||
*/
|
||||
public function testResolvesOwnAttachedFile(): void
|
||||
{
|
||||
$user = $this->makeUser();
|
||||
$file = $user->avatar()->create(['data' => $this->imagePath]);
|
||||
$user->reloadRelations();
|
||||
|
||||
$widget = $this->makeWidget($user);
|
||||
|
||||
$this->postFileId($file->id);
|
||||
$this->assertSameFile($file, $widget->exposedGetFileRecord());
|
||||
}
|
||||
|
||||
/**
|
||||
* The legitimate upload-in-progress case: a freshly uploaded file that is only
|
||||
* bound to the parent via the deferred-binding session (parent not yet saved)
|
||||
* must still resolve. This guards against a fix that scopes to the relation but
|
||||
* forgets ->withDeferred($sessionKey), which would break the upload workflow.
|
||||
*/
|
||||
public function testResolvesOwnDeferredFile(): void
|
||||
{
|
||||
$sessionKey = 'fileupload-scoping-deferred';
|
||||
|
||||
$user = $this->makeUser();
|
||||
|
||||
$file = new FileModel;
|
||||
$file->data = $this->imagePath;
|
||||
$file->save();
|
||||
$user->avatar()->add($file, $sessionKey);
|
||||
|
||||
$widget = $this->makeWidget($user, $sessionKey);
|
||||
|
||||
$this->postFileId($file->id);
|
||||
$this->assertSameFile($file, $widget->exposedGetFileRecord());
|
||||
}
|
||||
|
||||
/**
|
||||
* The security property: a file_id belonging to a DIFFERENT record must never
|
||||
* resolve, even though it is a valid row in the global system_files table.
|
||||
*/
|
||||
public function testDoesNotResolveAnotherRecordsFile(): void
|
||||
{
|
||||
$owner = $this->makeUser();
|
||||
$attacker = $this->makeUser();
|
||||
|
||||
$victimFile = $owner->avatar()->create(['data' => $this->imagePath]);
|
||||
$owner->reloadRelations();
|
||||
|
||||
// Widget is bound to the attacker's record, but posts the victim's file id.
|
||||
$widget = $this->makeWidget($attacker);
|
||||
$this->postFileId($victimFile->id);
|
||||
|
||||
$this->assertFalse(
|
||||
$widget->exposedGetFileRecord(),
|
||||
'A file_id from another record must not resolve through this widget.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A non-existent / empty file_id must resolve to false, not throw.
|
||||
*/
|
||||
public function testDoesNotResolveMissingOrEmptyFileId(): void
|
||||
{
|
||||
$widget = $this->makeWidget($this->makeUser());
|
||||
|
||||
$this->postFileId(999999);
|
||||
$this->assertFalse($widget->exposedGetFileRecord());
|
||||
|
||||
$this->postFileId(null);
|
||||
$this->assertFalse($widget->exposedGetFileRecord());
|
||||
}
|
||||
|
||||
/**
|
||||
* End-to-end assertion mirroring the advisory PoC at the widget level:
|
||||
* saving the attachment config while posting another record's file_id must
|
||||
* NOT mutate that file's metadata.
|
||||
*/
|
||||
public function testSaveConfigCannotMutateAnotherRecordsFile(): void
|
||||
{
|
||||
$owner = $this->makeUser();
|
||||
$attacker = $this->makeUser();
|
||||
|
||||
$victimFile = $owner->avatar()->create([
|
||||
'data' => $this->imagePath,
|
||||
'title' => 'original title',
|
||||
'description' => 'original description',
|
||||
]);
|
||||
$owner->reloadRelations();
|
||||
|
||||
// Mirror a real AJAX request: the POST payload (including file_id) is
|
||||
// present before the widget is constructed for the request.
|
||||
$this->postData([
|
||||
'file_id' => $victimFile->id,
|
||||
'avatar' => [
|
||||
'title' => 'hijacked title',
|
||||
'description' => 'hijacked description',
|
||||
],
|
||||
]);
|
||||
|
||||
$widget = $this->makeWidget($attacker);
|
||||
|
||||
// Either the handler rejects the out-of-scope file, or it silently no-ops;
|
||||
// either way the victim's metadata must be untouched.
|
||||
try {
|
||||
$widget->onSaveAttachmentConfig();
|
||||
} catch (ApplicationException $ex) {
|
||||
// Acceptable: handler refused to find the out-of-scope file.
|
||||
}
|
||||
|
||||
$victimFile = FileModel::find($victimFile->id);
|
||||
$this->assertSame('original title', $victimFile->title);
|
||||
$this->assertSame('original description', $victimFile->description);
|
||||
}
|
||||
|
||||
/**
|
||||
* The legitimate case: reordering files that belong to this record's own
|
||||
* relation must update their sort_order.
|
||||
*/
|
||||
public function testSortReordersOwnFiles(): void
|
||||
{
|
||||
$user = $this->makeUser();
|
||||
$first = $user->photos()->create(['data' => $this->imagePath]);
|
||||
$second = $user->photos()->create(['data' => $this->imagePath]);
|
||||
$user->reloadRelations();
|
||||
|
||||
$widget = $this->makeWidget($user, null, 'photos');
|
||||
|
||||
// Swap their order.
|
||||
$this->postData(['sortOrder' => [
|
||||
$first->id => 20,
|
||||
$second->id => 10,
|
||||
]]);
|
||||
$widget->onSortAttachments();
|
||||
|
||||
$this->assertEquals(20, FileModel::find($first->id)->sort_order);
|
||||
$this->assertEquals(10, FileModel::find($second->id)->sort_order);
|
||||
}
|
||||
|
||||
/**
|
||||
* The security property: onSortAttachments must not write sort_order to a
|
||||
* file that belongs to a different record, even with a valid file id.
|
||||
*/
|
||||
public function testSortIgnoresAnotherRecordsFile(): void
|
||||
{
|
||||
$owner = $this->makeUser();
|
||||
$attacker = $this->makeUser();
|
||||
|
||||
$victimFile = $owner->photos()->create(['data' => $this->imagePath]);
|
||||
$owner->reloadRelations();
|
||||
$originalOrder = FileModel::find($victimFile->id)->sort_order;
|
||||
|
||||
// Attacker drives their own photos widget but posts the victim's file id.
|
||||
$widget = $this->makeWidget($attacker, null, 'photos');
|
||||
$this->postData(['sortOrder' => [
|
||||
$victimFile->id => $originalOrder + 9999,
|
||||
]]);
|
||||
$widget->onSortAttachments();
|
||||
|
||||
$this->assertEquals(
|
||||
$originalOrder,
|
||||
FileModel::find($victimFile->id)->sort_order,
|
||||
'Sort order of another record\'s file must not change.'
|
||||
);
|
||||
}
|
||||
|
||||
//
|
||||
// Helpers
|
||||
//
|
||||
|
||||
protected function makeUser(): TesterUser
|
||||
{
|
||||
$user = new TesterUser;
|
||||
$user->name = 'Test User';
|
||||
$user->email = uniqid('user', true) . '@test.com';
|
||||
$user->save();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
protected function makeWidget(Model $model, ?string $sessionKey = null, string $relation = 'avatar'): FileUploadTestable
|
||||
{
|
||||
$formField = new FormField($relation, ucfirst($relation));
|
||||
$formField->valueFrom = $relation;
|
||||
|
||||
return new FileUploadTestable(new Controller, $formField, [
|
||||
'model' => $model,
|
||||
'sessionKey' => $sessionKey,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function postFileId($id): void
|
||||
{
|
||||
$this->postData(['file_id' => $id]);
|
||||
}
|
||||
|
||||
protected function postData(array $data): void
|
||||
{
|
||||
request()->setMethod('POST');
|
||||
request()->request->replace($data);
|
||||
}
|
||||
|
||||
protected function assertSameFile($expected, $actual): void
|
||||
{
|
||||
$this->assertInstanceOf(FileModel::class, $actual);
|
||||
$this->assertEquals($expected->id, $actual->id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exposes the protected getFileRecord() so the scoping boundary can be asserted
|
||||
* directly without rendering partials.
|
||||
*/
|
||||
class FileUploadTestable extends FileUpload
|
||||
{
|
||||
public function exposedGetFileRecord()
|
||||
{
|
||||
return $this->getFileRecord();
|
||||
}
|
||||
}
|
||||
28
modules/backend/tests/helpers/BackendHelperTest.php
Normal file
28
modules/backend/tests/helpers/BackendHelperTest.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Backend\Tests\Helpers;
|
||||
|
||||
use System\Tests\Bootstrap\TestCase;
|
||||
use Backend\Helpers\Backend;
|
||||
use Backend\Helpers\Exception\DecompileException;
|
||||
|
||||
class BackendHelperTest extends TestCase
|
||||
{
|
||||
public function testDecompileAssets()
|
||||
{
|
||||
$backendHelper = new Backend;
|
||||
$assets = $backendHelper->decompileAsset('modules/backend/tests/fixtures/assets/compilation.js');
|
||||
|
||||
$this->assertCount(2, $assets);
|
||||
$this->assertStringContainsString('file1.js', $assets[0]);
|
||||
$this->assertStringContainsString('file2.js', $assets[1]);
|
||||
}
|
||||
|
||||
public function testDecompileMissingFile()
|
||||
{
|
||||
$this->expectException(DecompileException::class);
|
||||
|
||||
$backendHelper = new Backend;
|
||||
$assets = $backendHelper->decompileAsset('modules/backend/tests/fixtures/assets/missing.js');
|
||||
}
|
||||
}
|
||||
138
modules/backend/tests/models/BrandSettingTest.php
Normal file
138
modules/backend/tests/models/BrandSettingTest.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace Backend\Tests\Models;
|
||||
|
||||
use Backend\Models\BrandSetting;
|
||||
use System\Tests\Bootstrap\PluginTestCase;
|
||||
|
||||
class BrandSettingTest extends PluginTestCase
|
||||
{
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Reset the cached instance so each test starts fresh
|
||||
\System\Behaviors\SettingsModel::clearInternalCache();
|
||||
}
|
||||
|
||||
public function tearDown(): void
|
||||
{
|
||||
// Clean up the settings record
|
||||
\Illuminate\Support\Facades\Cache::forget(BrandSetting::instance()->cacheKey);
|
||||
BrandSetting::instance()->resetDefault();
|
||||
\System\Behaviors\SettingsModel::clearInternalCache();
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that renderCss output does not contain script tags even when
|
||||
* malicious CSS using LESS escape syntax is stored in the database.
|
||||
*/
|
||||
public function testRenderCssStripsScriptTags()
|
||||
{
|
||||
$maliciousCss = '.x { content: ~"</style><script>alert(1)</script><style>"; }';
|
||||
|
||||
BrandSetting::set('custom_css', $maliciousCss);
|
||||
|
||||
\System\Behaviors\SettingsModel::clearInternalCache();
|
||||
\Illuminate\Support\Facades\Cache::forget(BrandSetting::instance()->cacheKey);
|
||||
|
||||
$renderedCss = BrandSetting::renderCss();
|
||||
|
||||
$this->assertStringNotContainsString('<script>', $renderedCss);
|
||||
$this->assertStringNotContainsString('</script>', $renderedCss);
|
||||
$this->assertStringNotContainsString('</style>', $renderedCss);
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression for GHSA-5cwr-5jxg-pcf6. renderCss() caches the raw compiler
|
||||
* output, so sanitizing only the cache-miss return leaves every later cache
|
||||
* hit unsanitized. The first render primes the cache; the second is the one
|
||||
* that used to emit active markup into the backend <style> block.
|
||||
*/
|
||||
public function testRenderCssStripsScriptTagsOnCacheHit()
|
||||
{
|
||||
$maliciousCss = '.x { content: ~"</style><script>alert(1)</script><style>"; }';
|
||||
|
||||
BrandSetting::set('custom_css', $maliciousCss);
|
||||
|
||||
\System\Behaviors\SettingsModel::clearInternalCache();
|
||||
\Illuminate\Support\Facades\Cache::forget(BrandSetting::instance()->cacheKey);
|
||||
|
||||
// Cache miss, primes the cache
|
||||
BrandSetting::renderCss();
|
||||
|
||||
// Cache hit
|
||||
$renderedCss = BrandSetting::renderCss();
|
||||
|
||||
$this->assertStringNotContainsString('<script>', $renderedCss);
|
||||
$this->assertStringNotContainsString('</script>', $renderedCss);
|
||||
$this->assertStringNotContainsString('</style>', $renderedCss);
|
||||
}
|
||||
|
||||
/**
|
||||
* A cache entry poisoned before GHSA-5cwr-5jxg-pcf6 was patched is not
|
||||
* cleared by upgrading, so it must still be sanitized when read back.
|
||||
*/
|
||||
public function testRenderCssStripsScriptTagsFromExistingCacheEntry()
|
||||
{
|
||||
\Illuminate\Support\Facades\Cache::forever(
|
||||
BrandSetting::instance()->cacheKey,
|
||||
'.x{content:</style><script>alert(1)</script><style>}'
|
||||
);
|
||||
|
||||
$renderedCss = BrandSetting::renderCss();
|
||||
|
||||
$this->assertStringNotContainsString('<script>', $renderedCss);
|
||||
$this->assertStringNotContainsString('</script>', $renderedCss);
|
||||
$this->assertStringNotContainsString('</style>', $renderedCss);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that normal CSS content is preserved through renderCss, on both the
|
||||
* cache miss and the cache hit that follows it.
|
||||
*/
|
||||
public function testRenderCssPreservesNormalCss()
|
||||
{
|
||||
$normalCss = '.my-class { color: red; font-size: 14px; }';
|
||||
|
||||
BrandSetting::set('custom_css', $normalCss);
|
||||
|
||||
\System\Behaviors\SettingsModel::clearInternalCache();
|
||||
\Illuminate\Support\Facades\Cache::forget(BrandSetting::instance()->cacheKey);
|
||||
|
||||
$renderedCss = BrandSetting::renderCss();
|
||||
|
||||
$this->assertStringContainsString('color', $renderedCss);
|
||||
$this->assertStringContainsString('font-size', $renderedCss);
|
||||
$this->assertDoesNotMatchRegularExpression('/<[a-z\/!]/', $renderedCss);
|
||||
|
||||
// Sanitizing the cache hit must not alter legitimate CSS
|
||||
$this->assertEquals($renderedCss, BrandSetting::renderCss());
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression for GHSA-58fp-mcx6-7qf9. A user-supplied `@import (inline)`
|
||||
* directive in `custom_css` must not be able to disclose server files.
|
||||
*/
|
||||
public function testRenderCssBlocksImportAttack()
|
||||
{
|
||||
$tmpSecret = tempnam(sys_get_temp_dir(), 'brandsetting-leak-canary-');
|
||||
file_put_contents($tmpSecret, "APP_KEY=do-not-leak-via-brandsetting\n");
|
||||
|
||||
try {
|
||||
BrandSetting::set('custom_css', '@import (inline) "' . $tmpSecret . '";');
|
||||
|
||||
\System\Behaviors\SettingsModel::clearInternalCache();
|
||||
\Illuminate\Support\Facades\Cache::forget(BrandSetting::instance()->cacheKey);
|
||||
|
||||
$renderedCss = BrandSetting::renderCss();
|
||||
|
||||
$this->assertStringNotContainsString('APP_KEY', $renderedCss);
|
||||
$this->assertStringNotContainsString('do-not-leak-via-brandsetting', $renderedCss);
|
||||
} finally {
|
||||
@unlink($tmpSecret);
|
||||
}
|
||||
}
|
||||
}
|
||||
138
modules/backend/tests/models/EditorSettingTest.php
Normal file
138
modules/backend/tests/models/EditorSettingTest.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace Backend\Tests\Models;
|
||||
|
||||
use Backend\Models\EditorSetting;
|
||||
use System\Tests\Bootstrap\PluginTestCase;
|
||||
|
||||
class EditorSettingTest extends PluginTestCase
|
||||
{
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Reset the cached instance so each test starts fresh
|
||||
\System\Behaviors\SettingsModel::clearInternalCache();
|
||||
}
|
||||
|
||||
public function tearDown(): void
|
||||
{
|
||||
// Clean up the settings record
|
||||
\Illuminate\Support\Facades\Cache::forget(EditorSetting::instance()->cacheKey);
|
||||
EditorSetting::instance()->resetDefault();
|
||||
\System\Behaviors\SettingsModel::clearInternalCache();
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that renderCss output does not contain script tags even when
|
||||
* malicious CSS using LESS escape syntax is stored in the database.
|
||||
*/
|
||||
public function testRenderCssStripsScriptTags()
|
||||
{
|
||||
$maliciousStyles = '.x { content: ~"</style><script>alert(1)</script><style>"; }';
|
||||
|
||||
EditorSetting::set('html_custom_styles', $maliciousStyles);
|
||||
|
||||
\System\Behaviors\SettingsModel::clearInternalCache();
|
||||
\Illuminate\Support\Facades\Cache::forget(EditorSetting::instance()->cacheKey);
|
||||
|
||||
$renderedCss = EditorSetting::renderCss();
|
||||
|
||||
$this->assertStringNotContainsString('<script>', $renderedCss);
|
||||
$this->assertStringNotContainsString('</script>', $renderedCss);
|
||||
$this->assertStringNotContainsString('</style>', $renderedCss);
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression for GHSA-5cwr-5jxg-pcf6. renderCss() caches the raw compiler
|
||||
* output, so sanitizing only the cache-miss return leaves every later cache
|
||||
* hit unsanitized. The first render primes the cache; the second is the one
|
||||
* that used to emit active markup into the backend <style> block.
|
||||
*/
|
||||
public function testRenderCssStripsScriptTagsOnCacheHit()
|
||||
{
|
||||
$maliciousStyles = '.x { content: ~"</style><script>alert(1)</script><style>"; }';
|
||||
|
||||
EditorSetting::set('html_custom_styles', $maliciousStyles);
|
||||
|
||||
\System\Behaviors\SettingsModel::clearInternalCache();
|
||||
\Illuminate\Support\Facades\Cache::forget(EditorSetting::instance()->cacheKey);
|
||||
|
||||
// Cache miss, primes the cache
|
||||
EditorSetting::renderCss();
|
||||
|
||||
// Cache hit
|
||||
$renderedCss = EditorSetting::renderCss();
|
||||
|
||||
$this->assertStringNotContainsString('<script>', $renderedCss);
|
||||
$this->assertStringNotContainsString('</script>', $renderedCss);
|
||||
$this->assertStringNotContainsString('</style>', $renderedCss);
|
||||
}
|
||||
|
||||
/**
|
||||
* A cache entry poisoned before GHSA-5cwr-5jxg-pcf6 was patched is not
|
||||
* cleared by upgrading, so it must still be sanitized when read back.
|
||||
*/
|
||||
public function testRenderCssStripsScriptTagsFromExistingCacheEntry()
|
||||
{
|
||||
\Illuminate\Support\Facades\Cache::forever(
|
||||
EditorSetting::instance()->cacheKey,
|
||||
'.fr-view .x{content:</style><script>alert(1)</script><style>}'
|
||||
);
|
||||
|
||||
$renderedCss = EditorSetting::renderCss();
|
||||
|
||||
$this->assertStringNotContainsString('<script>', $renderedCss);
|
||||
$this->assertStringNotContainsString('</script>', $renderedCss);
|
||||
$this->assertStringNotContainsString('</style>', $renderedCss);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that normal CSS content is preserved through renderCss, on both the
|
||||
* cache miss and the cache hit that follows it.
|
||||
*/
|
||||
public function testRenderCssPreservesNormalCss()
|
||||
{
|
||||
$normalStyles = '.my-class { color: blue; font-weight: bold; }';
|
||||
|
||||
EditorSetting::set('html_custom_styles', $normalStyles);
|
||||
|
||||
\System\Behaviors\SettingsModel::clearInternalCache();
|
||||
\Illuminate\Support\Facades\Cache::forget(EditorSetting::instance()->cacheKey);
|
||||
|
||||
$renderedCss = EditorSetting::renderCss();
|
||||
|
||||
$this->assertStringContainsString('color', $renderedCss);
|
||||
$this->assertStringContainsString('font-weight', $renderedCss);
|
||||
$this->assertDoesNotMatchRegularExpression('/<[a-z\/!]/', $renderedCss);
|
||||
|
||||
// Sanitizing the cache hit must not alter legitimate CSS
|
||||
$this->assertEquals($renderedCss, EditorSetting::renderCss());
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression for GHSA-58fp-mcx6-7qf9. A user-supplied `@import (inline)`
|
||||
* directive in `html_custom_styles` must not be able to disclose server files.
|
||||
*/
|
||||
public function testRenderCssBlocksImportAttack()
|
||||
{
|
||||
$tmpSecret = tempnam(sys_get_temp_dir(), 'editorsetting-leak-canary-');
|
||||
file_put_contents($tmpSecret, "APP_KEY=do-not-leak-via-editorsetting\n");
|
||||
|
||||
try {
|
||||
EditorSetting::set('html_custom_styles', '@import (inline) "' . $tmpSecret . '";');
|
||||
|
||||
\System\Behaviors\SettingsModel::clearInternalCache();
|
||||
\Illuminate\Support\Facades\Cache::forget(EditorSetting::instance()->cacheKey);
|
||||
|
||||
$renderedCss = EditorSetting::renderCss();
|
||||
|
||||
$this->assertStringNotContainsString('APP_KEY', $renderedCss);
|
||||
$this->assertStringNotContainsString('do-not-leak-via-editorsetting', $renderedCss);
|
||||
} finally {
|
||||
@unlink($tmpSecret);
|
||||
}
|
||||
}
|
||||
}
|
||||
94
modules/backend/tests/models/ExportModelTest.php
Normal file
94
modules/backend/tests/models/ExportModelTest.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace Backend\Tests\Models;
|
||||
|
||||
use System\Tests\Bootstrap\TestCase;
|
||||
use Backend\Models\ExportModel;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
if (!class_exists('Model')) {
|
||||
class_alias('Winter\Storm\Database\Model', 'Model');
|
||||
}
|
||||
|
||||
class ExampleExportModel extends ExportModel
|
||||
{
|
||||
public function exportData($columns, $sessionKey = null)
|
||||
{
|
||||
return [
|
||||
[
|
||||
'foo' => 'bar',
|
||||
'bar' => 'foo',
|
||||
'foobar' => 'Hello World!',
|
||||
],
|
||||
[
|
||||
'foo' => 'bar2',
|
||||
'bar' => 'foo2',
|
||||
'foobar' => 'Hello World2!',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class ExportModelTest extends TestCase
|
||||
{
|
||||
|
||||
//
|
||||
// Tests
|
||||
//
|
||||
|
||||
public function testEncodeArrayValue()
|
||||
{
|
||||
$model = new ExampleExportModel;
|
||||
$data = ['foo', 'bar'];
|
||||
$result = self::callProtectedMethod($model, 'encodeArrayValue', [$data]);
|
||||
$this->assertEquals('foo|bar', $result);
|
||||
|
||||
$data = ['dps | heals | tank', 'paladin', 'berserker', 'gunner'];
|
||||
$result = self::callProtectedMethod($model, 'encodeArrayValue', [$data]);
|
||||
$this->assertEquals('dps \| heals \| tank|paladin|berserker|gunner', $result);
|
||||
|
||||
$data = ['art direction', 'roman empire', 'sci-fi'];
|
||||
$result = self::callProtectedMethod($model, 'encodeArrayValue', [$data, '-']);
|
||||
$this->assertEquals('art direction-roman empire-sci\-fi', $result);
|
||||
}
|
||||
|
||||
public function testDownload()
|
||||
{
|
||||
$model = new ExampleExportModel;
|
||||
|
||||
$csvName = $model->export(['foo' => 'title', 'bar' => 'title2'], []);
|
||||
|
||||
$response = $model->download($csvName);
|
||||
|
||||
$request = new Request();
|
||||
|
||||
$response->prepare($request);
|
||||
|
||||
$this->assertTrue($response->headers->has('Content-Type'), "Response is missing the Content-Type header!");
|
||||
|
||||
$contentType = $response->headers->get('Content-Type');
|
||||
$this->assertTrue(
|
||||
str_contains($contentType, 'application/csv')
|
||||
|| str_contains($contentType, 'text/plain')
|
||||
|| str_contains($contentType, 'text/csv'),
|
||||
"Content-Type is not as expected, provided: " . $contentType
|
||||
);
|
||||
|
||||
ob_start();
|
||||
$response->send();
|
||||
$output = ob_get_clean();
|
||||
|
||||
$utf8BOM = chr(239) . chr(187) . chr(191);
|
||||
|
||||
$this->assertEquals($utf8BOM . "title,title2\nbar,foo\nbar2,foo2\n", $output, "CSV is not right!");
|
||||
|
||||
$filePath = temp_path($csvName);
|
||||
|
||||
$fileGotDeleted = !is_file($filePath);
|
||||
|
||||
$this->assertTrue($fileGotDeleted, "Export-CSV doesn't get deleted.");
|
||||
if (!$fileGotDeleted) {
|
||||
unlink($filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
70
modules/backend/tests/models/ImportModelTest.php
Normal file
70
modules/backend/tests/models/ImportModelTest.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace Backend\Tests\Models;
|
||||
|
||||
use System\Tests\Bootstrap\PluginTestCase;
|
||||
use Backend\Models\ImportModel;
|
||||
use System\Models\File as FileModel;
|
||||
|
||||
if (!class_exists('Model')) {
|
||||
class_alias('Winter\Storm\Database\Model', 'Model');
|
||||
}
|
||||
|
||||
class ExampleImportModel extends ImportModel
|
||||
{
|
||||
public $rules = [];
|
||||
|
||||
public function importData($results, $sessionKey = null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
class ImportModelTest extends PluginTestCase
|
||||
{
|
||||
|
||||
//
|
||||
// Tests
|
||||
//
|
||||
|
||||
public function testDecodeArrayValue()
|
||||
{
|
||||
$model = new ExampleImportModel;
|
||||
$data = 'foo|bar';
|
||||
$result = self::callProtectedMethod($model, 'decodeArrayValue', [$data]);
|
||||
$this->assertEquals(['foo', 'bar'], $result);
|
||||
|
||||
$data = 'dps \| heals \| tank|paladin|berserker|gunner';
|
||||
$result = self::callProtectedMethod($model, 'decodeArrayValue', [$data]);
|
||||
$this->assertEquals(['dps | heals | tank', 'paladin', 'berserker', 'gunner'], $result);
|
||||
|
||||
$data = 'art direction-roman empire-sci\-fi';
|
||||
$result = self::callProtectedMethod($model, 'decodeArrayValue', [$data, '-']);
|
||||
$this->assertEquals(['art direction', 'roman empire', 'sci-fi'], $result);
|
||||
}
|
||||
|
||||
public function testGetImportFilePath()
|
||||
{
|
||||
$model = new ExampleImportModel;
|
||||
$sessionKey = uniqid('session_key', true);
|
||||
|
||||
$file1 = FileModel::create([
|
||||
'data' => base_path().'/modules/backend/tests/fixtures/reference/file1.txt',
|
||||
'is_public' => false,
|
||||
]);
|
||||
|
||||
$file2 = FileModel::create([
|
||||
'data' => base_path().'/modules/backend/tests/fixtures/reference/file2.txt',
|
||||
'is_public' => false,
|
||||
]);
|
||||
|
||||
$model->import_file()->add($file1, $sessionKey);
|
||||
$model->import_file()->add($file2, $sessionKey);
|
||||
|
||||
$this->assertEquals(
|
||||
$file2->getLocalPath(),
|
||||
$model->getImportFilePath($sessionKey),
|
||||
'ImportModel::getImportFilePath() should return the last uploaded file.'
|
||||
);
|
||||
}
|
||||
}
|
||||
480
modules/backend/tests/models/UserAuthorizationTest.php
Normal file
480
modules/backend/tests/models/UserAuthorizationTest.php
Normal file
@@ -0,0 +1,480 @@
|
||||
<?php
|
||||
|
||||
namespace Backend\Tests\Models;
|
||||
|
||||
use Backend\Facades\BackendAuth;
|
||||
use Backend\Models\User;
|
||||
use Backend\Models\UserGroup;
|
||||
use Backend\Tests\Fixtures\Models\UserFixture;
|
||||
use System\Tests\Bootstrap\PluginTestCase;
|
||||
use Winter\Storm\Auth\AuthorizationException;
|
||||
use Winter\Storm\Database\Model;
|
||||
|
||||
class UserAuthorizationTest extends PluginTestCase
|
||||
{
|
||||
protected User $targetUser;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Create a real target user in the database for operations that need it
|
||||
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();
|
||||
}
|
||||
|
||||
//
|
||||
// canBeManagedByUser()
|
||||
//
|
||||
|
||||
public function testCanBeManagedByUserReturnsTrueForCli(): void
|
||||
{
|
||||
// No authenticated user (CLI context)
|
||||
BackendAuth::logout();
|
||||
$this->assertTrue($this->targetUser->canBeManagedByUser());
|
||||
}
|
||||
|
||||
public function testCanBeManagedByUserReturnsFalseWithoutPermission(): void
|
||||
{
|
||||
$actor = (new UserFixture)->withPermission('backend.manage_users', false);
|
||||
$this->assertFalse($this->targetUser->canBeManagedByUser($actor));
|
||||
}
|
||||
|
||||
public function testCanBeManagedByUserReturnsTrueWithPermission(): void
|
||||
{
|
||||
$actor = (new UserFixture)->withPermission('backend.manage_users', true);
|
||||
$this->assertTrue($this->targetUser->canBeManagedByUser($actor));
|
||||
}
|
||||
|
||||
public function testCanBeManagedByUserReturnsFalseForNonSuperuserTargetingSuperuser(): void
|
||||
{
|
||||
$this->targetUser->is_superuser = true;
|
||||
$actor = (new UserFixture)->withPermission('backend.manage_users', true);
|
||||
$this->assertFalse($this->targetUser->canBeManagedByUser($actor));
|
||||
}
|
||||
|
||||
public function testCanBeManagedByUserReturnsTrueForSuperuserTargetingSuperuser(): void
|
||||
{
|
||||
$this->targetUser->is_superuser = true;
|
||||
$actor = (new UserFixture)->asSuperUser()->withPermission('backend.manage_users', true);
|
||||
$this->assertTrue($this->targetUser->canBeManagedByUser($actor));
|
||||
}
|
||||
|
||||
public function testCanBeManagedByUserChecksPreviousSuperuserStatus(): void
|
||||
{
|
||||
// Simulate a record that WAS a superuser (getOriginal returns true)
|
||||
$this->targetUser->syncOriginal();
|
||||
$this->targetUser->is_superuser = true;
|
||||
$this->targetUser->syncOriginal();
|
||||
$this->targetUser->is_superuser = false;
|
||||
|
||||
$actor = (new UserFixture)->withPermission('backend.manage_users', true);
|
||||
// getOriginal('is_superuser') is true, so non-superuser can't manage
|
||||
$this->assertFalse($this->targetUser->canBeManagedByUser($actor));
|
||||
}
|
||||
|
||||
public function testCanBeManagedByUserFallsBackToAuthenticatedUser(): void
|
||||
{
|
||||
$actor = (new UserFixture)->withPermission('backend.manage_users', true);
|
||||
$this->actingAs($actor);
|
||||
|
||||
// No explicit user passed — falls back to authenticated user
|
||||
$this->assertTrue($this->targetUser->canBeManagedByUser());
|
||||
}
|
||||
|
||||
//
|
||||
// beforeCreate() / beforeUpdate()
|
||||
//
|
||||
|
||||
public function testUpdateAllowsCliContext(): void
|
||||
{
|
||||
BackendAuth::logout();
|
||||
$this->targetUser->first_name = 'Updated';
|
||||
$this->targetUser->save();
|
||||
$this->assertEquals('Updated', $this->targetUser->first_name);
|
||||
}
|
||||
|
||||
public function testUpdateAllowsSelfNonEscalatingChanges(): void
|
||||
{
|
||||
$this->actingAs($this->targetUser);
|
||||
$this->targetUser->first_name = 'NewName';
|
||||
$this->targetUser->save();
|
||||
$this->assertEquals('NewName', $this->targetUser->first_name);
|
||||
}
|
||||
|
||||
public function testUpdateBlocksSelfEscalation(): void
|
||||
{
|
||||
$this->actingAs($this->targetUser);
|
||||
$this->targetUser->is_superuser = true;
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$this->targetUser->save();
|
||||
}
|
||||
|
||||
public function testUpdateBlocksUnauthorizedUserModifyingOthers(): void
|
||||
{
|
||||
$actor = new UserFixture;
|
||||
$this->actingAs($actor);
|
||||
|
||||
$this->targetUser->first_name = 'Hacked';
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$this->targetUser->save();
|
||||
}
|
||||
|
||||
public function testUpdateAllowsManageUsersActorModifyingOthers(): void
|
||||
{
|
||||
$actor = (new UserFixture)->withPermission('backend.manage_users', true);
|
||||
$this->actingAs($actor);
|
||||
|
||||
$this->targetUser->first_name = 'AdminUpdated';
|
||||
$this->targetUser->save();
|
||||
$this->assertEquals('AdminUpdated', $this->targetUser->first_name);
|
||||
}
|
||||
|
||||
public function testSaveWithNoChangesRequiresNoPermission(): void
|
||||
{
|
||||
$actor = new UserFixture;
|
||||
$this->actingAs($actor);
|
||||
|
||||
// The guard fires on create/update, which only happen when there is
|
||||
// something to write — a no-op save is allowed for anyone (#1464)
|
||||
try {
|
||||
$this->targetUser->save();
|
||||
} catch (AuthorizationException $e) {
|
||||
$this->fail('Should not throw AuthorizationException for a save with no changes');
|
||||
}
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function testCreateBlocksUnauthorizedUser(): void
|
||||
{
|
||||
$actor = new UserFixture;
|
||||
$this->actingAs($actor);
|
||||
|
||||
$user = new User;
|
||||
$user->first_name = 'New';
|
||||
$user->last_name = 'User';
|
||||
$user->login = 'newuser';
|
||||
$user->email = 'new@test.com';
|
||||
$user->password = 'TestPassword1';
|
||||
$user->password_confirmation = 'TestPassword1';
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$user->save();
|
||||
}
|
||||
|
||||
public function testUpdateBlocksNonSuperuserModifyingSuperuser(): void
|
||||
{
|
||||
// Make the target a superuser via direct DB update to bypass model events
|
||||
$this->targetUser->newQuery()->where('id', $this->targetUser->id)->update(['is_superuser' => true]);
|
||||
|
||||
// Re-fetch the model fresh to avoid stale password hash triggering validation
|
||||
$superTarget = User::find($this->targetUser->id);
|
||||
|
||||
$actor = (new UserFixture)->withPermission('backend.manage_users', true);
|
||||
$this->actingAs($actor);
|
||||
|
||||
$superTarget->first_name = 'Hacked';
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$superTarget->save();
|
||||
}
|
||||
|
||||
public function testUpdateSuperuserCanModifyAnyone(): void
|
||||
{
|
||||
$actor = (new UserFixture)->asSuperUser();
|
||||
$this->actingAs($actor);
|
||||
|
||||
$this->targetUser->first_name = 'SuperUpdated';
|
||||
$this->targetUser->save();
|
||||
$this->assertEquals('SuperUpdated', $this->targetUser->first_name);
|
||||
}
|
||||
|
||||
//
|
||||
// Group membership (model.relation.* guard)
|
||||
//
|
||||
|
||||
protected function makeGroup(): UserGroup
|
||||
{
|
||||
return UserGroup::create([
|
||||
'name' => 'Test Group',
|
||||
'code' => 'test-group',
|
||||
]);
|
||||
}
|
||||
|
||||
public function testGroupAttachAllowsManageUsersActor(): void
|
||||
{
|
||||
$group = $this->makeGroup();
|
||||
|
||||
$actor = (new UserFixture)->withPermission('backend.manage_users', true);
|
||||
$this->actingAs($actor);
|
||||
|
||||
$this->targetUser->groups()->add($group);
|
||||
$this->assertEquals(1, $this->targetUser->groups()->count());
|
||||
}
|
||||
|
||||
public function testGroupAttachBlocksUnauthorizedUser(): void
|
||||
{
|
||||
$group = $this->makeGroup();
|
||||
|
||||
$actor = new UserFixture;
|
||||
$this->actingAs($actor);
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$this->targetUser->groups()->add($group);
|
||||
}
|
||||
|
||||
public function testGroupDetachBlocksUnauthorizedUser(): void
|
||||
{
|
||||
$group = $this->makeGroup();
|
||||
$this->targetUser->groups()->add($group);
|
||||
|
||||
$actor = new UserFixture;
|
||||
$this->actingAs($actor);
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$this->targetUser->groups()->remove($group);
|
||||
}
|
||||
|
||||
public function testGroupSyncAssignmentBlocksUnauthorizedUser(): void
|
||||
{
|
||||
$group = $this->makeGroup();
|
||||
|
||||
$actor = new UserFixture;
|
||||
$this->actingAs($actor);
|
||||
|
||||
// Assigning a relation value queues a sync that runs during save without
|
||||
// dirtying any attributes — the relation guard must still catch it
|
||||
$this->targetUser->groups = [$group->id];
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$this->targetUser->save();
|
||||
}
|
||||
|
||||
public function testPluginAddedRelationIsNotGuarded(): void
|
||||
{
|
||||
$group = $this->makeGroup();
|
||||
|
||||
// Simulate a plugin-added relation on the user model; only the relations
|
||||
// core owns (listed in $permissionGuardedRelations) require the permission
|
||||
$this->targetUser->belongsToMany['memberships'] = [
|
||||
UserGroup::class,
|
||||
'table' => 'backend_users_groups',
|
||||
];
|
||||
|
||||
$actor = new UserFixture;
|
||||
$this->actingAs($actor);
|
||||
|
||||
$this->targetUser->memberships()->add($group);
|
||||
$this->assertEquals(1, $this->targetUser->memberships()->count());
|
||||
}
|
||||
|
||||
public function testSelfGroupChangeAllowed(): void
|
||||
{
|
||||
$group = $this->makeGroup();
|
||||
|
||||
$this->actingAs($this->targetUser);
|
||||
|
||||
// Groups carry no permissions out of the box, so changing your own
|
||||
// membership is not an escalation vector and needs no permission
|
||||
$this->targetUser->groups()->add($group);
|
||||
$this->assertEquals(1, $this->targetUser->groups()->count());
|
||||
}
|
||||
|
||||
//
|
||||
// beforeDelete()
|
||||
//
|
||||
|
||||
public function testBeforeDeleteAllowsCliContext(): void
|
||||
{
|
||||
BackendAuth::logout();
|
||||
$this->targetUser->delete();
|
||||
$this->assertTrue($this->targetUser->trashed());
|
||||
}
|
||||
|
||||
public function testBeforeDeleteBlocksUnauthorizedUser(): void
|
||||
{
|
||||
$actor = new UserFixture;
|
||||
$this->actingAs($actor);
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$this->targetUser->delete();
|
||||
}
|
||||
|
||||
public function testBeforeDeleteAllowsManageUsersActor(): void
|
||||
{
|
||||
$actor = (new UserFixture)->withPermission('backend.manage_users', true);
|
||||
$this->actingAs($actor);
|
||||
|
||||
$this->targetUser->delete();
|
||||
$this->assertTrue($this->targetUser->trashed());
|
||||
}
|
||||
|
||||
public function testBeforeDeleteBlocksNonSuperuserDeletingSuperuser(): void
|
||||
{
|
||||
$this->targetUser->newQuery()->where('id', $this->targetUser->id)->update(['is_superuser' => true]);
|
||||
$this->targetUser->refresh();
|
||||
|
||||
$actor = (new UserFixture)->withPermission('backend.manage_users', true);
|
||||
$this->actingAs($actor);
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$this->targetUser->delete();
|
||||
}
|
||||
|
||||
public function testBeforeDeleteSuperuserCanDeleteAnyone(): void
|
||||
{
|
||||
$actor = (new UserFixture)->asSuperUser();
|
||||
$this->actingAs($actor);
|
||||
|
||||
$this->targetUser->delete();
|
||||
$this->assertTrue($this->targetUser->trashed());
|
||||
}
|
||||
|
||||
//
|
||||
// beforeRestore()
|
||||
//
|
||||
|
||||
public function testBeforeRestoreAllowsCliContext(): void
|
||||
{
|
||||
BackendAuth::logout();
|
||||
$this->targetUser->delete();
|
||||
$this->targetUser->restore();
|
||||
$this->assertFalse($this->targetUser->trashed());
|
||||
}
|
||||
|
||||
public function testBeforeRestoreBlocksUnauthorizedUser(): void
|
||||
{
|
||||
BackendAuth::logout();
|
||||
$this->targetUser->delete();
|
||||
|
||||
$actor = new UserFixture;
|
||||
$this->actingAs($actor);
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$this->targetUser->restore();
|
||||
}
|
||||
|
||||
public function testBeforeRestoreAllowsManageUsersActor(): void
|
||||
{
|
||||
BackendAuth::logout();
|
||||
$this->targetUser->delete();
|
||||
|
||||
$actor = (new UserFixture)->withPermission('backend.manage_users', true);
|
||||
$this->actingAs($actor);
|
||||
|
||||
$this->targetUser->restore();
|
||||
$this->assertFalse($this->targetUser->trashed());
|
||||
}
|
||||
|
||||
public function testBeforeRestoreBlocksNonSuperuserRestoringSuperuser(): void
|
||||
{
|
||||
$this->targetUser->newQuery()->where('id', $this->targetUser->id)->update(['is_superuser' => true]);
|
||||
$this->targetUser->refresh();
|
||||
BackendAuth::logout();
|
||||
$this->targetUser->delete();
|
||||
|
||||
$actor = (new UserFixture)->withPermission('backend.manage_users', true);
|
||||
$this->actingAs($actor);
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$this->targetUser->restore();
|
||||
}
|
||||
|
||||
public function testBeforeRestoreSuperuserCanRestoreAnyone(): void
|
||||
{
|
||||
BackendAuth::logout();
|
||||
$this->targetUser->delete();
|
||||
|
||||
$actor = (new UserFixture)->asSuperUser();
|
||||
$this->actingAs($actor);
|
||||
|
||||
$this->targetUser->restore();
|
||||
$this->assertFalse($this->targetUser->trashed());
|
||||
}
|
||||
|
||||
//
|
||||
// unsuspend()
|
||||
//
|
||||
|
||||
public function testUnsuspendBlocksUnauthorizedUser(): void
|
||||
{
|
||||
$actor = new UserFixture;
|
||||
$this->actingAs($actor);
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$this->targetUser->unsuspend();
|
||||
}
|
||||
|
||||
public function testUnsuspendAllowsManageUsersActor(): void
|
||||
{
|
||||
$actor = (new UserFixture)->withPermission('backend.manage_users', true);
|
||||
$this->actingAs($actor);
|
||||
|
||||
// Should not throw — the throttle record may not exist but that's fine
|
||||
// for testing the authorization gate
|
||||
try {
|
||||
$this->targetUser->unsuspend();
|
||||
} catch (AuthorizationException $e) {
|
||||
$this->fail('Should not throw AuthorizationException for manage_users actor');
|
||||
}
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function testUnsuspendAllowsCliContext(): void
|
||||
{
|
||||
BackendAuth::logout();
|
||||
|
||||
try {
|
||||
$this->targetUser->unsuspend();
|
||||
} catch (AuthorizationException $e) {
|
||||
$this->fail('Should not throw AuthorizationException in CLI context');
|
||||
}
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
//
|
||||
// getResetPasswordCode()
|
||||
//
|
||||
|
||||
public function testGetResetPasswordCodeAllowsSelfService(): void
|
||||
{
|
||||
$this->actingAs($this->targetUser);
|
||||
$code = $this->targetUser->getResetPasswordCode();
|
||||
$this->assertNotEmpty($code);
|
||||
}
|
||||
|
||||
public function testGetResetPasswordCodeBlocksUnauthorizedUserForOthers(): void
|
||||
{
|
||||
$actor = new UserFixture;
|
||||
$this->actingAs($actor);
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$this->targetUser->getResetPasswordCode();
|
||||
}
|
||||
|
||||
public function testGetResetPasswordCodeAllowsManageUsersActorForOthers(): void
|
||||
{
|
||||
$actor = (new UserFixture)->withPermission('backend.manage_users', true);
|
||||
$this->actingAs($actor);
|
||||
|
||||
$code = $this->targetUser->getResetPasswordCode();
|
||||
$this->assertNotEmpty($code);
|
||||
}
|
||||
|
||||
public function testGetResetPasswordCodeAllowsCliContext(): void
|
||||
{
|
||||
BackendAuth::logout();
|
||||
$code = $this->targetUser->getResetPasswordCode();
|
||||
$this->assertNotEmpty($code);
|
||||
}
|
||||
}
|
||||
224
modules/backend/tests/models/UserTest.php
Normal file
224
modules/backend/tests/models/UserTest.php
Normal file
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
namespace Backend\Tests\Models;
|
||||
|
||||
use Backend\Models\User;
|
||||
use Backend\Models\UserRole;
|
||||
use System\Tests\Bootstrap\PluginTestCase;
|
||||
use Winter\Storm\Auth\AuthorizationException;
|
||||
use Winter\Storm\Database\Model as Eloquent;
|
||||
|
||||
class UserTest extends PluginTestCase
|
||||
{
|
||||
protected User $superuser;
|
||||
protected User $admin;
|
||||
protected User $lowPriv;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Eloquent::unguarded(function () {
|
||||
$developerRole = UserRole::where('code', UserRole::CODE_DEVELOPER)->first();
|
||||
$publisherRole = UserRole::where('code', UserRole::CODE_PUBLISHER)->first();
|
||||
|
||||
$this->superuser = User::create([
|
||||
'email' => 'superuser@test.com',
|
||||
'login' => 'superuser',
|
||||
'password' => 'Testing123!',
|
||||
'password_confirmation' => 'Testing123!',
|
||||
'first_name' => 'Super',
|
||||
'last_name' => 'User',
|
||||
'is_superuser' => true,
|
||||
'is_activated' => true,
|
||||
'role_id' => $developerRole->id,
|
||||
'permissions' => [],
|
||||
]);
|
||||
|
||||
$this->admin = User::create([
|
||||
'email' => 'admin@test.com',
|
||||
'login' => 'admin_user',
|
||||
'password' => 'Testing123!',
|
||||
'password_confirmation' => 'Testing123!',
|
||||
'first_name' => 'Admin',
|
||||
'last_name' => 'User',
|
||||
'is_superuser' => false,
|
||||
'is_activated' => true,
|
||||
'role_id' => $publisherRole->id,
|
||||
'permissions' => ['backend.manage_users' => 1],
|
||||
]);
|
||||
|
||||
$this->lowPriv = User::create([
|
||||
'email' => 'lowpriv@test.com',
|
||||
'login' => 'lowpriv',
|
||||
'password' => 'Testing123!',
|
||||
'password_confirmation' => 'Testing123!',
|
||||
'first_name' => 'Low',
|
||||
'last_name' => 'Priv',
|
||||
'is_superuser' => false,
|
||||
'is_activated' => true,
|
||||
'role_id' => null,
|
||||
'permissions' => [],
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Denied operations ----
|
||||
|
||||
public function testSelfEditRoleThrows()
|
||||
{
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
$this->admin->role_id = $this->admin->role_id + 1;
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$this->admin->save();
|
||||
}
|
||||
|
||||
public function testSelfEditSuperuserThrows()
|
||||
{
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
$this->admin->is_superuser = true;
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$this->admin->save();
|
||||
}
|
||||
|
||||
public function testSelfEditPermissionsThrows()
|
||||
{
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
$this->admin->permissions = ['backend.manage_users' => 1, 'backend.access_dashboard' => 1];
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$this->admin->save();
|
||||
}
|
||||
|
||||
public function testNonSuperuserGrantSuperuserThrows()
|
||||
{
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
$this->lowPriv->is_superuser = true;
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$this->lowPriv->save();
|
||||
}
|
||||
|
||||
public function testNonSuperuserRevokeSuperuserThrows()
|
||||
{
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
$this->superuser->is_superuser = false;
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$this->superuser->save();
|
||||
}
|
||||
|
||||
public function testNonSuperuserEditingSuperuserThrows()
|
||||
{
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
$this->superuser->first_name = 'Changed';
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$this->superuser->save();
|
||||
}
|
||||
|
||||
public function testNoManageUsersEditingOtherThrows()
|
||||
{
|
||||
$this->actingAs($this->lowPriv);
|
||||
|
||||
$this->admin->first_name = 'Changed';
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$this->admin->save();
|
||||
}
|
||||
|
||||
public function testNoManageUsersCreatingUserThrows()
|
||||
{
|
||||
$this->actingAs($this->lowPriv);
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
Eloquent::unguarded(function () {
|
||||
User::create([
|
||||
'email' => 'newuser@test.com',
|
||||
'login' => 'newuser',
|
||||
'password' => 'Testing123!',
|
||||
'password_confirmation' => 'Testing123!',
|
||||
'first_name' => 'New',
|
||||
'last_name' => 'User',
|
||||
'is_superuser' => false,
|
||||
'is_activated' => true,
|
||||
'permissions' => [],
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Allowed operations ----
|
||||
|
||||
public function testSelfEditNonProtectedFieldsAllowed()
|
||||
{
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
$this->admin->first_name = 'NewFirst';
|
||||
$this->admin->last_name = 'NewLast';
|
||||
$this->admin->email = 'newemail@test.com';
|
||||
$this->admin->save();
|
||||
|
||||
$this->admin->refresh();
|
||||
$this->assertEquals('NewFirst', $this->admin->first_name);
|
||||
$this->assertEquals('NewLast', $this->admin->last_name);
|
||||
$this->assertEquals('newemail@test.com', $this->admin->email);
|
||||
}
|
||||
|
||||
public function testAdminCanChangeOtherUserRole()
|
||||
{
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
$publisherRole = UserRole::where('code', UserRole::CODE_PUBLISHER)->first();
|
||||
$this->lowPriv->role_id = $publisherRole->id;
|
||||
$this->lowPriv->save();
|
||||
|
||||
$this->lowPriv->refresh();
|
||||
$this->assertEquals($publisherRole->id, $this->lowPriv->role_id);
|
||||
}
|
||||
|
||||
public function testSuperuserCanGrantSuperuser()
|
||||
{
|
||||
$this->actingAs($this->superuser);
|
||||
|
||||
$this->lowPriv->is_superuser = true;
|
||||
$this->lowPriv->save();
|
||||
|
||||
$this->lowPriv->refresh();
|
||||
$this->assertTrue((bool) $this->lowPriv->is_superuser);
|
||||
}
|
||||
|
||||
public function testAdminCanChangeOtherUserPermissions()
|
||||
{
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
$this->lowPriv->permissions = ['backend.access_dashboard' => 1];
|
||||
$this->lowPriv->save();
|
||||
|
||||
$this->lowPriv->refresh();
|
||||
$this->assertEquals(['backend.access_dashboard' => 1], $this->lowPriv->permissions);
|
||||
}
|
||||
|
||||
public function testNoAuthUserCanModifyAnyField()
|
||||
{
|
||||
// No actingAs — simulates CLI/artisan/queue context
|
||||
$developerRole = UserRole::where('code', UserRole::CODE_DEVELOPER)->first();
|
||||
|
||||
$this->lowPriv->role_id = $developerRole->id;
|
||||
$this->lowPriv->is_superuser = true;
|
||||
$this->lowPriv->permissions = ['backend.manage_users' => 1];
|
||||
$this->lowPriv->save();
|
||||
|
||||
$this->lowPriv->refresh();
|
||||
$this->assertEquals($developerRole->id, $this->lowPriv->role_id);
|
||||
$this->assertTrue((bool) $this->lowPriv->is_superuser);
|
||||
$this->assertEquals(['backend.manage_users' => 1], $this->lowPriv->permissions);
|
||||
}
|
||||
}
|
||||
64
modules/backend/tests/traits/WidgetMakerTest.php
Normal file
64
modules/backend/tests/traits/WidgetMakerTest.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace Backend\Tests\Traits;
|
||||
|
||||
use System\Tests\Bootstrap\TestCase;
|
||||
use Backend\Classes\Controller;
|
||||
|
||||
class ExampleTraitClass
|
||||
{
|
||||
use \Backend\Traits\WidgetMaker;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->controller = new Controller;
|
||||
}
|
||||
}
|
||||
|
||||
class WidgetMakerTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* The object under test.
|
||||
*
|
||||
* @var object
|
||||
*/
|
||||
private $traitObject;
|
||||
|
||||
/**
|
||||
* Sets up the fixture.
|
||||
*
|
||||
* This method is called before a test is executed.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$traitName = 'Backend\Traits\WidgetMaker';
|
||||
$this->traitObject = $this->getObjectForTrait($traitName);
|
||||
}
|
||||
|
||||
public function testTraitObject()
|
||||
{
|
||||
$maker = $this->traitObject;
|
||||
|
||||
$widget = $maker->makeWidget('Backend\Widgets\Search');
|
||||
$this->assertInstanceOf('Backend\Widgets\Search', $widget);
|
||||
}
|
||||
|
||||
public function testMakeWidget()
|
||||
{
|
||||
$manager = new ExampleTraitClass;
|
||||
|
||||
$controller = new Controller;
|
||||
$widget = $manager->makeWidget('Backend\Widgets\Search');
|
||||
$this->assertInstanceOf('Backend\Widgets\Search', $widget);
|
||||
$this->assertInstanceOf('Backend\Classes\Controller', $widget->getController());
|
||||
|
||||
$config = ['test' => 'config'];
|
||||
$widget = $manager->makeWidget('Backend\Widgets\Search', $config);
|
||||
$this->assertInstanceOf('Backend\Widgets\Search', $widget);
|
||||
$this->assertEquals('config', $widget->getConfig('test'));
|
||||
}
|
||||
}
|
||||
552
modules/backend/tests/widgets/FilterWidgetTest.php
Normal file
552
modules/backend/tests/widgets/FilterWidgetTest.php
Normal file
@@ -0,0 +1,552 @@
|
||||
<?php
|
||||
|
||||
namespace Backend\Tests\Widgets;
|
||||
|
||||
use ApplicationException;
|
||||
use Backend\Models\User;
|
||||
use Backend\Tests\Fixtures\Models\UserFixture;
|
||||
use Backend\Widgets\Filter;
|
||||
use Carbon\Carbon;
|
||||
use System\Tests\Bootstrap\PluginTestCase;
|
||||
|
||||
class FilterWidgetTest extends PluginTestCase
|
||||
{
|
||||
//
|
||||
// Permission / scope restriction tests (existing)
|
||||
//
|
||||
|
||||
public function testRestrictedScopeWithUserWithNoPermissions()
|
||||
{
|
||||
$user = new UserFixture;
|
||||
$this->actingAs($user);
|
||||
|
||||
$filter = $this->restrictedFilterFixture();
|
||||
$filter->render();
|
||||
|
||||
$this->assertNotNull($filter->getScope('id'));
|
||||
|
||||
// Expect an exception
|
||||
$this->expectException(ApplicationException::class);
|
||||
$this->expectExceptionMessage('No definition for scope email');
|
||||
$scope = $filter->getScope('email');
|
||||
}
|
||||
|
||||
public function testRestrictedScopeWithUserWithWrongPermissions()
|
||||
{
|
||||
$user = new UserFixture;
|
||||
$this->actingAs($user->withPermission('test.wrong_permission', true));
|
||||
|
||||
$filter = $this->restrictedFilterFixture();
|
||||
$filter->render();
|
||||
|
||||
$this->assertNotNull($filter->getScope('id'));
|
||||
|
||||
// Expect an exception
|
||||
$this->expectException(ApplicationException::class);
|
||||
$this->expectExceptionMessage('No definition for scope email');
|
||||
$scope = $filter->getScope('email');
|
||||
}
|
||||
|
||||
public function testRestrictedScopeWithUserWithRightPermissions()
|
||||
{
|
||||
$user = new UserFixture;
|
||||
$this->actingAs($user->withPermission('test.access_field', true));
|
||||
|
||||
$filter = $this->restrictedFilterFixture();
|
||||
$filter->render();
|
||||
|
||||
$this->assertNotNull($filter->getScope('id'));
|
||||
$this->assertNotNull($filter->getScope('email'));
|
||||
}
|
||||
|
||||
public function testRestrictedScopeWithUserWithRightWildcardPermissions()
|
||||
{
|
||||
$user = new UserFixture;
|
||||
$this->actingAs($user->withPermission('test.access_field', true));
|
||||
|
||||
$filter = new Filter(null, [
|
||||
'model' => new User,
|
||||
'arrayName' => 'array',
|
||||
'scopes' => [
|
||||
'id' => [
|
||||
'type' => 'text',
|
||||
'label' => 'ID'
|
||||
],
|
||||
'email' => [
|
||||
'type' => 'text',
|
||||
'label' => 'Email',
|
||||
'permission' => 'test.*'
|
||||
]
|
||||
]
|
||||
]);
|
||||
$filter->render();
|
||||
|
||||
$this->assertNotNull($filter->getScope('id'));
|
||||
$this->assertNotNull($filter->getScope('email'));
|
||||
}
|
||||
|
||||
public function testRestrictedScopeWithSuperuser()
|
||||
{
|
||||
$user = new UserFixture;
|
||||
$this->actingAs($user->asSuperUser());
|
||||
|
||||
$filter = $this->restrictedFilterFixture();
|
||||
$filter->render();
|
||||
|
||||
$this->assertNotNull($filter->getScope('id'));
|
||||
$this->assertNotNull($filter->getScope('email'));
|
||||
}
|
||||
|
||||
public function testRestrictedScopeSinglePermissionWithUserWithWrongPermissions()
|
||||
{
|
||||
$user = new UserFixture;
|
||||
$this->actingAs($user->withPermission('test.wrong_permission', true));
|
||||
|
||||
$filter = $this->restrictedFilterFixture(true);
|
||||
$filter->render();
|
||||
|
||||
$this->assertNotNull($filter->getScope('id'));
|
||||
|
||||
// Expect an exception
|
||||
$this->expectException(ApplicationException::class);
|
||||
$this->expectExceptionMessage('No definition for scope email');
|
||||
$scope = $filter->getScope('email');
|
||||
}
|
||||
|
||||
public function testRestrictedScopeSinglePermissionWithUserWithRightPermissions()
|
||||
{
|
||||
$user = new UserFixture;
|
||||
$this->actingAs($user->withPermission('test.access_field', true));
|
||||
|
||||
$filter = $this->restrictedFilterFixture(true);
|
||||
$filter->render();
|
||||
|
||||
$this->assertNotNull($filter->getScope('id'));
|
||||
$this->assertNotNull($filter->getScope('email'));
|
||||
}
|
||||
|
||||
//
|
||||
// numbersFromAjax() validation tests
|
||||
//
|
||||
|
||||
public function testNumbersFromAjaxValidIntegers()
|
||||
{
|
||||
$filter = $this->createFilterWithScope('test', ['type' => 'numberrange', 'label' => 'Test']);
|
||||
$result = static::callProtectedMethod($filter, 'numbersFromAjax', [['10', '20']]);
|
||||
$this->assertSame([10.0, 20.0], $result);
|
||||
}
|
||||
|
||||
public function testNumbersFromAjaxValidFloats()
|
||||
{
|
||||
$filter = $this->createFilterWithScope('test', ['type' => 'numberrange', 'label' => 'Test']);
|
||||
$result = static::callProtectedMethod($filter, 'numbersFromAjax', [['10.5', '20.99']]);
|
||||
$this->assertSame([10.5, 20.99], $result);
|
||||
}
|
||||
|
||||
public function testNumbersFromAjaxValidNegatives()
|
||||
{
|
||||
$filter = $this->createFilterWithScope('test', ['type' => 'numberrange', 'label' => 'Test']);
|
||||
$result = static::callProtectedMethod($filter, 'numbersFromAjax', [['-5', '100']]);
|
||||
$this->assertSame([-5.0, 100.0], $result);
|
||||
}
|
||||
|
||||
public function testNumbersFromAjaxRejectsSqlInjection()
|
||||
{
|
||||
$filter = $this->createFilterWithScope('test', ['type' => 'numberrange', 'label' => 'Test']);
|
||||
$result = static::callProtectedMethod($filter, 'numbersFromAjax', [['0', '9999 OR 1=1--']]);
|
||||
$this->assertSame([0.0, null], $result);
|
||||
}
|
||||
|
||||
public function testNumbersFromAjaxEmptyArray()
|
||||
{
|
||||
$filter = $this->createFilterWithScope('test', ['type' => 'numberrange', 'label' => 'Test']);
|
||||
$result = static::callProtectedMethod($filter, 'numbersFromAjax', [[]]);
|
||||
$this->assertSame([], $result);
|
||||
}
|
||||
|
||||
public function testNumbersFromAjaxScalarNumeric()
|
||||
{
|
||||
$filter = $this->createFilterWithScope('test', ['type' => 'number', 'label' => 'Test']);
|
||||
$result = static::callProtectedMethod($filter, 'numbersFromAjax', ['42']);
|
||||
$this->assertSame([42.0], $result);
|
||||
}
|
||||
|
||||
public function testNumbersFromAjaxScalarInvalid()
|
||||
{
|
||||
$filter = $this->createFilterWithScope('test', ['type' => 'number', 'label' => 'Test']);
|
||||
$result = static::callProtectedMethod($filter, 'numbersFromAjax', ['abc']);
|
||||
$this->assertSame([], $result);
|
||||
}
|
||||
|
||||
//
|
||||
// datesFromAjax() validation tests
|
||||
//
|
||||
|
||||
public function testDatesFromAjaxValidDates()
|
||||
{
|
||||
$filter = $this->createFilterWithScope('test', ['type' => 'daterange', 'label' => 'Test']);
|
||||
$result = static::callProtectedMethod($filter, 'datesFromAjax', [['2024-01-01 00:00:00', '2024-12-31 23:59:59']]);
|
||||
|
||||
$this->assertCount(2, $result);
|
||||
$this->assertInstanceOf(Carbon::class, $result[0]);
|
||||
$this->assertInstanceOf(Carbon::class, $result[1]);
|
||||
$this->assertEquals('2024-01-01 00:00:00', $result[0]->format('Y-m-d H:i:s'));
|
||||
$this->assertEquals('2024-12-31 23:59:59', $result[1]->format('Y-m-d H:i:s'));
|
||||
}
|
||||
|
||||
public function testDatesFromAjaxEmptyBoundaries()
|
||||
{
|
||||
$filter = $this->createFilterWithScope('test', ['type' => 'daterange', 'label' => 'Test']);
|
||||
$result = static::callProtectedMethod($filter, 'datesFromAjax', [['', '']]);
|
||||
|
||||
$this->assertCount(2, $result);
|
||||
$this->assertInstanceOf(Carbon::class, $result[0]);
|
||||
$this->assertInstanceOf(Carbon::class, $result[1]);
|
||||
$this->assertEquals('0000-01-01 00:00:00', $result[0]->format('Y-m-d H:i:s'));
|
||||
$this->assertEquals('2999-12-31 23:59:59', $result[1]->format('Y-m-d H:i:s'));
|
||||
}
|
||||
|
||||
public function testDatesFromAjaxRejectsInvalid()
|
||||
{
|
||||
$filter = $this->createFilterWithScope('test', ['type' => 'daterange', 'label' => 'Test']);
|
||||
$result = static::callProtectedMethod($filter, 'datesFromAjax', [['not-a-date', '2024-01-01 00:00:00']]);
|
||||
$this->assertSame([], $result);
|
||||
}
|
||||
|
||||
public function testDatesFromAjaxNull()
|
||||
{
|
||||
$filter = $this->createFilterWithScope('test', ['type' => 'daterange', 'label' => 'Test']);
|
||||
$result = static::callProtectedMethod($filter, 'datesFromAjax', [null]);
|
||||
$this->assertSame([], $result);
|
||||
}
|
||||
|
||||
//
|
||||
// applyScopeToQuery() — numberrange (primary security fix)
|
||||
//
|
||||
|
||||
public function testNumberrangeConditionsExecutesQuery()
|
||||
{
|
||||
$this->actingAs((new UserFixture)->asSuperUser());
|
||||
|
||||
$filter = $this->createFilterWithScope('id_range', [
|
||||
'type' => 'numberrange',
|
||||
'label' => 'ID Range',
|
||||
'conditions' => 'id >= :min AND id <= :max',
|
||||
]);
|
||||
$filter->render();
|
||||
|
||||
$scope = $filter->getScope('id_range');
|
||||
$scope->value = [1, 100];
|
||||
|
||||
$query = (new User)->newQuery();
|
||||
$filter->applyScopeToQuery($scope, $query);
|
||||
|
||||
$this->assertStringContainsString('id >= ?', $query->toSql());
|
||||
$this->assertStringContainsString('id <= ?', $query->toSql());
|
||||
$this->assertEquals([1.0, 100.0], $query->getBindings());
|
||||
|
||||
// Actually execute — will throw on binding mismatch
|
||||
$this->assertIsInt($query->count());
|
||||
}
|
||||
|
||||
public function testNumberrangeConditionsBindingsMatchPlaceholderOrder()
|
||||
{
|
||||
$this->actingAs((new UserFixture)->asSuperUser());
|
||||
|
||||
$filter = $this->createFilterWithScope('id_range', [
|
||||
'type' => 'numberrange',
|
||||
'label' => 'ID Range',
|
||||
'conditions' => 'id <= :max AND id >= :min',
|
||||
]);
|
||||
$filter->render();
|
||||
|
||||
$scope = $filter->getScope('id_range');
|
||||
$scope->value = [1, 100];
|
||||
|
||||
$query = (new User)->newQuery();
|
||||
$filter->applyScopeToQuery($scope, $query);
|
||||
|
||||
// :max appears before :min in the conditions, so 100.0 must come first
|
||||
$this->assertEquals([100.0, 1.0], $query->getBindings());
|
||||
|
||||
// Actually execute — wrong order would produce incorrect results
|
||||
$this->assertIsInt($query->count());
|
||||
}
|
||||
|
||||
public function testNumberrangeConditionsWithQuotedPlaceholders()
|
||||
{
|
||||
$this->actingAs((new UserFixture)->asSuperUser());
|
||||
|
||||
// Plugin configs commonly wrap placeholders in SQL quotes: ':min'
|
||||
$filter = $this->createFilterWithScope('id_range', [
|
||||
'type' => 'numberrange',
|
||||
'label' => 'ID Range',
|
||||
'conditions' => "id >= ':min' AND id <= ':max'",
|
||||
]);
|
||||
$filter->render();
|
||||
|
||||
$scope = $filter->getScope('id_range');
|
||||
$scope->value = [1, 100];
|
||||
|
||||
$query = (new User)->newQuery();
|
||||
$filter->applyScopeToQuery($scope, $query);
|
||||
|
||||
// Quotes must be stripped so PDO sees ? as a parameter, not '?'
|
||||
$this->assertStringContainsString('id >= ?', $query->toSql());
|
||||
$this->assertStringNotContainsString("'?'", $query->toSql());
|
||||
$this->assertEquals([1.0, 100.0], $query->getBindings());
|
||||
$this->assertIsInt($query->count());
|
||||
}
|
||||
|
||||
public function testNumberConditionsWithQuotedPlaceholder()
|
||||
{
|
||||
$this->actingAs((new UserFixture)->asSuperUser());
|
||||
|
||||
$filter = $this->createFilterWithScope('user_id', [
|
||||
'type' => 'number',
|
||||
'label' => 'User ID',
|
||||
'conditions' => "id = ':filtered'",
|
||||
]);
|
||||
$filter->render();
|
||||
|
||||
$scope = $filter->getScope('user_id');
|
||||
$scope->value = 1;
|
||||
|
||||
$query = (new User)->newQuery();
|
||||
$filter->applyScopeToQuery($scope, $query);
|
||||
|
||||
$this->assertStringContainsString('id = ?', $query->toSql());
|
||||
$this->assertStringNotContainsString("'?'", $query->toSql());
|
||||
$this->assertEquals([1.0], $query->getBindings());
|
||||
$this->assertIsInt($query->count());
|
||||
}
|
||||
|
||||
public function testNumberrangeConditionsNullMin()
|
||||
{
|
||||
$this->actingAs((new UserFixture)->asSuperUser());
|
||||
|
||||
$filter = $this->createFilterWithScope('id_range', [
|
||||
'type' => 'numberrange',
|
||||
'label' => 'ID Range',
|
||||
'conditions' => 'id >= :min AND id <= :max',
|
||||
]);
|
||||
$filter->render();
|
||||
|
||||
$scope = $filter->getScope('id_range');
|
||||
$scope->value = [null, 100];
|
||||
|
||||
$query = (new User)->newQuery();
|
||||
$filter->applyScopeToQuery($scope, $query);
|
||||
|
||||
$this->assertEquals([-2147483647, 100.0], $query->getBindings());
|
||||
$this->assertIsInt($query->count());
|
||||
}
|
||||
|
||||
public function testNumberrangeConditionsNullMax()
|
||||
{
|
||||
$this->actingAs((new UserFixture)->asSuperUser());
|
||||
|
||||
$filter = $this->createFilterWithScope('id_range', [
|
||||
'type' => 'numberrange',
|
||||
'label' => 'ID Range',
|
||||
'conditions' => 'id >= :min AND id <= :max',
|
||||
]);
|
||||
$filter->render();
|
||||
|
||||
$scope = $filter->getScope('id_range');
|
||||
$scope->value = [1, null];
|
||||
|
||||
$query = (new User)->newQuery();
|
||||
$filter->applyScopeToQuery($scope, $query);
|
||||
|
||||
$this->assertEquals([1.0, 2147483647], $query->getBindings());
|
||||
$this->assertIsInt($query->count());
|
||||
}
|
||||
|
||||
public function testNumberrangeEmptyValue()
|
||||
{
|
||||
$this->actingAs((new UserFixture)->asSuperUser());
|
||||
|
||||
$filter = $this->createFilterWithScope('id_range', [
|
||||
'type' => 'numberrange',
|
||||
'label' => 'ID Range',
|
||||
'conditions' => 'id >= :min AND id <= :max',
|
||||
]);
|
||||
$filter->render();
|
||||
|
||||
$scope = $filter->getScope('id_range');
|
||||
$scope->value = null;
|
||||
|
||||
$query = (new User)->newQuery();
|
||||
$filter->applyScopeToQuery($scope, $query);
|
||||
|
||||
$this->assertEmpty($query->getBindings());
|
||||
}
|
||||
|
||||
//
|
||||
// applyScopeToQuery() — number (defense-in-depth)
|
||||
//
|
||||
|
||||
public function testNumberConditionsExecutesQuery()
|
||||
{
|
||||
$this->actingAs((new UserFixture)->asSuperUser());
|
||||
|
||||
$filter = $this->createFilterWithScope('user_id', [
|
||||
'type' => 'number',
|
||||
'label' => 'User ID',
|
||||
'conditions' => 'id = :filtered',
|
||||
]);
|
||||
$filter->render();
|
||||
|
||||
$scope = $filter->getScope('user_id');
|
||||
$scope->value = 1;
|
||||
|
||||
$query = (new User)->newQuery();
|
||||
$filter->applyScopeToQuery($scope, $query);
|
||||
|
||||
$this->assertStringContainsString('id = ?', $query->toSql());
|
||||
$this->assertEquals([1.0], $query->getBindings());
|
||||
$this->assertIsInt($query->count());
|
||||
}
|
||||
|
||||
public function testNumberNonNumericValueIgnored()
|
||||
{
|
||||
$this->actingAs((new UserFixture)->asSuperUser());
|
||||
|
||||
$filter = $this->createFilterWithScope('user_id', [
|
||||
'type' => 'number',
|
||||
'label' => 'User ID',
|
||||
'conditions' => 'id = :filtered',
|
||||
]);
|
||||
$filter->render();
|
||||
|
||||
$scope = $filter->getScope('user_id');
|
||||
$scope->value = 'abc';
|
||||
|
||||
$query = (new User)->newQuery();
|
||||
$filter->applyScopeToQuery($scope, $query);
|
||||
|
||||
$this->assertEmpty($query->getBindings());
|
||||
}
|
||||
|
||||
//
|
||||
// applyScopeToQuery() — date (defense-in-depth)
|
||||
//
|
||||
|
||||
public function testDateConditionsExecutesQuery()
|
||||
{
|
||||
$this->actingAs((new UserFixture)->asSuperUser());
|
||||
|
||||
$filter = $this->createFilterWithScope('created', [
|
||||
'type' => 'date',
|
||||
'label' => 'Created',
|
||||
'conditions' => 'created_at >= :after AND created_at <= :before',
|
||||
]);
|
||||
$filter->render();
|
||||
|
||||
$scope = $filter->getScope('created');
|
||||
$scope->value = Carbon::create(2024, 6, 15, 0, 0, 0);
|
||||
|
||||
$query = (new User)->newQuery();
|
||||
$filter->applyScopeToQuery($scope, $query);
|
||||
|
||||
$this->assertStringContainsString('created_at >= ?', $query->toSql());
|
||||
$this->assertStringContainsString('created_at <= ?', $query->toSql());
|
||||
$this->assertCount(2, $query->getBindings());
|
||||
$this->assertEquals('2024-06-15 00:00:00', $query->getBindings()[0]);
|
||||
$this->assertEquals('2024-06-15 23:59:00', $query->getBindings()[1]);
|
||||
$this->assertIsInt($query->count());
|
||||
}
|
||||
|
||||
//
|
||||
// applyScopeToQuery() — daterange (defense-in-depth)
|
||||
//
|
||||
|
||||
public function testDaterangeConditionsExecutesQuery()
|
||||
{
|
||||
$this->actingAs((new UserFixture)->asSuperUser());
|
||||
|
||||
$filter = $this->createFilterWithScope('created_range', [
|
||||
'type' => 'daterange',
|
||||
'label' => 'Created',
|
||||
'conditions' => 'created_at >= :after AND created_at <= :before',
|
||||
]);
|
||||
$filter->render();
|
||||
|
||||
$scope = $filter->getScope('created_range');
|
||||
$scope->value = [
|
||||
Carbon::create(2024, 1, 1, 0, 0, 0),
|
||||
Carbon::create(2024, 12, 31, 23, 59, 59),
|
||||
];
|
||||
|
||||
$query = (new User)->newQuery();
|
||||
$filter->applyScopeToQuery($scope, $query);
|
||||
|
||||
$this->assertStringContainsString('created_at >= ?', $query->toSql());
|
||||
$this->assertStringContainsString('created_at <= ?', $query->toSql());
|
||||
$this->assertCount(2, $query->getBindings());
|
||||
$this->assertEquals('2024-01-01 00:00:00', $query->getBindings()[0]);
|
||||
$this->assertEquals('2024-12-31 23:59:59', $query->getBindings()[1]);
|
||||
$this->assertIsInt($query->count());
|
||||
}
|
||||
|
||||
//
|
||||
// applyScopeToQuery() — text (regression test, existing behavior)
|
||||
//
|
||||
|
||||
public function testTextConditionsExecutesQuery()
|
||||
{
|
||||
$this->actingAs((new UserFixture)->asSuperUser());
|
||||
|
||||
$filter = $this->createFilterWithScope('name', [
|
||||
'type' => 'text',
|
||||
'label' => 'Name',
|
||||
'conditions' => 'first_name LIKE :value',
|
||||
]);
|
||||
$filter->render();
|
||||
|
||||
$scope = $filter->getScope('name');
|
||||
$scope->value = '%test%';
|
||||
|
||||
$query = (new User)->newQuery();
|
||||
$filter->applyScopeToQuery($scope, $query);
|
||||
|
||||
// Text scope uses PDO::quote() inline rather than bindings
|
||||
$this->assertStringContainsString('first_name LIKE', $query->toSql());
|
||||
$this->assertIsInt($query->count());
|
||||
}
|
||||
|
||||
//
|
||||
// Helpers
|
||||
//
|
||||
|
||||
protected function restrictedFilterFixture(bool $singlePermission = false)
|
||||
{
|
||||
return new Filter(null, [
|
||||
'model' => new User,
|
||||
'arrayName' => 'array',
|
||||
'scopes' => [
|
||||
'id' => [
|
||||
'type' => 'text',
|
||||
'label' => 'ID'
|
||||
],
|
||||
'email' => [
|
||||
'type' => 'text',
|
||||
'label' => 'Email',
|
||||
'permissions' => ($singlePermission) ? 'test.access_field' : [
|
||||
'test.access_field'
|
||||
]
|
||||
]
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
protected function createFilterWithScope(string $name, array $scopeConfig): Filter
|
||||
{
|
||||
return new Filter(null, [
|
||||
'model' => new User,
|
||||
'arrayName' => 'array',
|
||||
'scopes' => [$name => $scopeConfig],
|
||||
]);
|
||||
}
|
||||
}
|
||||
196
modules/backend/tests/widgets/FormFieldSetSaveTest.php
Normal file
196
modules/backend/tests/widgets/FormFieldSetSaveTest.php
Normal file
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
namespace Backend\Tests\Widgets
|
||||
{
|
||||
use Backend\Classes\Controller;
|
||||
use Backend\Classes\FormField;
|
||||
use Backend\Classes\FormWidgetBase;
|
||||
use Backend\Classes\WidgetManager;
|
||||
use Backend\FormWidgets\FieldSet;
|
||||
use System\Tests\Bootstrap\PluginTestCase;
|
||||
use Winter\Storm\Database\Model;
|
||||
use Backend\Widgets\Form;
|
||||
|
||||
class FormFieldSetSaveTestModel extends Model
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* A trivial form widget whose getSaveValue() observably transforms the posted value,
|
||||
* used to prove that a fieldset's nested `widget` fields have getSaveValue() applied.
|
||||
*/
|
||||
class FieldSetSaveStubWidget extends FormWidgetBase
|
||||
{
|
||||
protected $defaultAlias = 'fieldsetsavestub';
|
||||
|
||||
public function getSaveValue($value)
|
||||
{
|
||||
return is_string($value) ? strtoupper($value) : $value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A nested form widget that opts out of saving (as e.g. FileUpload does, since its
|
||||
* relation manages persistence). Its NO_SAVE_DATA sentinel must never reach the model.
|
||||
*/
|
||||
class FieldSetNoSaveStubWidget extends FormWidgetBase
|
||||
{
|
||||
protected $defaultAlias = 'fieldsetnosavestub';
|
||||
|
||||
public function getSaveValue($value)
|
||||
{
|
||||
return FormField::NO_SAVE_DATA;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Covers Form::getSaveData()'s handling of the `fieldset` form widget: the fieldset
|
||||
* visually groups fields but they must be saved as if they were regular fields at the
|
||||
* parent level, while the fieldset container itself saves nothing.
|
||||
*/
|
||||
class FormFieldSetSaveTest extends PluginTestCase
|
||||
{
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// The backend module's form widgets are not auto-registered under PluginTestCase,
|
||||
// so make the aliases used by the form field configs below resolvable.
|
||||
WidgetManager::instance()->registerFormWidget(FieldSet::class, 'fieldset');
|
||||
WidgetManager::instance()->registerFormWidget(FieldSetSaveStubWidget::class, 'fieldsetsavestub');
|
||||
WidgetManager::instance()->registerFormWidget(FieldSetNoSaveStubWidget::class, 'fieldsetnosavestub');
|
||||
}
|
||||
|
||||
protected function makeForm(): Form
|
||||
{
|
||||
return new Form(new Controller, [
|
||||
'model' => new FormFieldSetSaveTestModel,
|
||||
'arrayName' => 'array',
|
||||
'fields' => [
|
||||
'top_level' => [
|
||||
'type' => 'text',
|
||||
],
|
||||
'group' => [
|
||||
'type' => 'fieldset',
|
||||
'label' => 'Grouped Fields',
|
||||
'fields' => [
|
||||
'nested_text' => [
|
||||
'type' => 'text',
|
||||
],
|
||||
'nested_number' => [
|
||||
'type' => 'number',
|
||||
],
|
||||
'nested_widget' => [
|
||||
'type' => 'fieldsetsavestub',
|
||||
],
|
||||
'nested_nosave' => [
|
||||
'type' => 'fieldsetnosavestub',
|
||||
],
|
||||
'nested_disabled' => [
|
||||
'type' => 'text',
|
||||
'disabled' => true,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* getSaveData() reads its values with post(), which only returns real data when
|
||||
* the request is genuinely a POST -- so simulate the postback first.
|
||||
*/
|
||||
protected function postForm(array $data): array
|
||||
{
|
||||
request()->setMethod('POST');
|
||||
request()->request->replace(['array' => $data]);
|
||||
|
||||
return $this->makeForm()->getSaveData();
|
||||
}
|
||||
|
||||
public function testNestedFieldSetFieldsAreCollectedToParentLevel()
|
||||
{
|
||||
$data = $this->postForm([
|
||||
'top_level' => 'top',
|
||||
'nested_text' => 'hello',
|
||||
'nested_number' => '42',
|
||||
]);
|
||||
|
||||
// Nested fields are hoisted to the parent save data...
|
||||
$this->assertArrayHasKey('nested_text', $data);
|
||||
$this->assertArrayHasKey('nested_number', $data);
|
||||
$this->assertEquals('hello', $data['nested_text']);
|
||||
|
||||
// ...and the top level field is unaffected.
|
||||
$this->assertEquals('top', $data['top_level']);
|
||||
|
||||
// The fieldset container itself must not be saved.
|
||||
$this->assertArrayNotHasKey('group', $data);
|
||||
}
|
||||
|
||||
public function testNestedNumberFieldIsCastToFloat()
|
||||
{
|
||||
$data = $this->postForm([
|
||||
'nested_number' => '42',
|
||||
]);
|
||||
|
||||
$this->assertSame(42.0, $data['nested_number']);
|
||||
}
|
||||
|
||||
public function testNestedNumberEmptyStringBecomesNull()
|
||||
{
|
||||
$data = $this->postForm([
|
||||
'nested_number' => ' ',
|
||||
]);
|
||||
|
||||
$this->assertArrayHasKey('nested_number', $data);
|
||||
$this->assertNull($data['nested_number']);
|
||||
}
|
||||
|
||||
public function testNestedWidgetFieldHasSaveValueApplied()
|
||||
{
|
||||
$data = $this->postForm([
|
||||
'nested_widget' => 'abc',
|
||||
]);
|
||||
|
||||
// The nested widget's getSaveValue() must be applied (stub upper-cases it),
|
||||
// rather than the raw posted value being stored.
|
||||
$this->assertArrayHasKey('nested_widget', $data);
|
||||
$this->assertSame('ABC', $data['nested_widget']);
|
||||
}
|
||||
|
||||
public function testNestedWidgetReturningNoSaveDataDoesNotLeakSentinel()
|
||||
{
|
||||
$data = $this->postForm([
|
||||
'nested_nosave' => 'ignore me',
|
||||
]);
|
||||
|
||||
// A nested widget that returns NO_SAVE_DATA must behave exactly as it would
|
||||
// at the top level of a form: the NO_SAVE_DATA sentinel (-1) must never be
|
||||
// written to the model.
|
||||
$this->assertNotSame(FormField::NO_SAVE_DATA, $data['nested_nosave'] ?? null);
|
||||
}
|
||||
|
||||
public function testDisabledNestedFieldIsNotSaved()
|
||||
{
|
||||
$data = $this->postForm([
|
||||
'nested_text' => 'hello',
|
||||
'nested_disabled' => 'tampered',
|
||||
]);
|
||||
|
||||
// Disabled fields are omitted from the save data, just like top-level fields.
|
||||
$this->assertArrayHasKey('nested_text', $data);
|
||||
$this->assertArrayNotHasKey('nested_disabled', $data);
|
||||
}
|
||||
|
||||
public function testMissingNestedValuesAreOmitted()
|
||||
{
|
||||
$data = $this->postForm([
|
||||
'top_level' => 'top',
|
||||
]);
|
||||
|
||||
$this->assertArrayNotHasKey('nested_text', $data);
|
||||
$this->assertArrayNotHasKey('nested_number', $data);
|
||||
}
|
||||
}
|
||||
}
|
||||
252
modules/backend/tests/widgets/FormTest.php
Normal file
252
modules/backend/tests/widgets/FormTest.php
Normal file
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
namespace Backend\Tests\Widgets
|
||||
{
|
||||
use System\Tests\Bootstrap\PluginTestCase;
|
||||
use Winter\Storm\Database\Model;
|
||||
use Backend\Tests\Fixtures\Models\UserFixture;
|
||||
use Backend\Widgets\Form;
|
||||
|
||||
class FormTestModel extends Model
|
||||
{
|
||||
public function modelCustomOptionsMethod()
|
||||
{
|
||||
return ['model', 'custom', 'options'];
|
||||
}
|
||||
|
||||
public function collectionOptions()
|
||||
{
|
||||
return collect(['collection', 'options']);
|
||||
}
|
||||
|
||||
public function getFieldNameOnModelOptionsMethodOptions()
|
||||
{
|
||||
return ['model', 'field name', 'options method'];
|
||||
}
|
||||
|
||||
public function getDropdownOptions()
|
||||
{
|
||||
return ['dropdown', 'options'];
|
||||
}
|
||||
|
||||
public function staticMethodOptions()
|
||||
{
|
||||
return ['static', 'method'];
|
||||
}
|
||||
}
|
||||
|
||||
class FormTest extends PluginTestCase
|
||||
{
|
||||
public function testRestrictedFieldWithUserWithNoPermissions()
|
||||
{
|
||||
$user = new UserFixture;
|
||||
$this->actingAs($user);
|
||||
|
||||
$form = $this->restrictedFormFixture();
|
||||
|
||||
$form->render();
|
||||
$this->assertNull($form->getField('testRestricted'));
|
||||
}
|
||||
|
||||
public function testRestrictedFieldWithUserWithWrongPermissions()
|
||||
{
|
||||
$user = new UserFixture;
|
||||
$this->actingAs($user->withPermission('test.wrong_permission', true));
|
||||
|
||||
$form = $this->restrictedFormFixture();
|
||||
|
||||
$form->render();
|
||||
$this->assertNull($form->getField('testRestricted'));
|
||||
}
|
||||
|
||||
public function testRestrictedFieldWithUserWithRightPermissions()
|
||||
{
|
||||
$user = new UserFixture;
|
||||
$this->actingAs($user->withPermission('test.access_field', true));
|
||||
|
||||
$form = $this->restrictedFormFixture();
|
||||
|
||||
$form->render();
|
||||
$this->assertNotNull($form->getField('testRestricted'));
|
||||
}
|
||||
|
||||
public function testRestrictedFieldWithUserWithRightWildcardPermissions()
|
||||
{
|
||||
$user = new UserFixture;
|
||||
$this->actingAs($user->withPermission('test.access_field', true));
|
||||
|
||||
$form = new Form(null, [
|
||||
'model' => new FormTestModel,
|
||||
'arrayName' => 'array',
|
||||
'fields' => [
|
||||
'testField' => [
|
||||
'type' => 'text',
|
||||
'label' => 'Test 1'
|
||||
],
|
||||
'testRestricted' => [
|
||||
'type' => 'text',
|
||||
'label' => 'Test 2',
|
||||
'permission' => 'test.*'
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
$form->render();
|
||||
$this->assertNotNull($form->getField('testRestricted'));
|
||||
}
|
||||
|
||||
public function testRestrictedFieldWithSuperuser()
|
||||
{
|
||||
$user = new UserFixture;
|
||||
$this->actingAs($user->asSuperUser());
|
||||
|
||||
$form = $this->restrictedFormFixture();
|
||||
|
||||
$form->render();
|
||||
$this->assertNotNull($form->getField('testRestricted'));
|
||||
}
|
||||
|
||||
public function testRestrictedFieldSinglePermissionWithUserWithWrongPermissions()
|
||||
{
|
||||
$user = new UserFixture;
|
||||
$this->actingAs($user->withPermission('test.wrong_permission', true));
|
||||
|
||||
$form = $this->restrictedFormFixture(true);
|
||||
|
||||
$form->render();
|
||||
$this->assertNull($form->getField('testRestricted'));
|
||||
}
|
||||
|
||||
public function testRestrictedFieldSinglePermissionWithUserWithRightPermissions()
|
||||
{
|
||||
$user = new UserFixture;
|
||||
$this->actingAs($user->withPermission('test.access_field', true));
|
||||
|
||||
$form = $this->restrictedFormFixture(true);
|
||||
|
||||
$form->render();
|
||||
$this->assertNotNull($form->getField('testRestricted'));
|
||||
}
|
||||
|
||||
public function testCheckboxlistTrigger()
|
||||
{
|
||||
$form = new Form(null, [
|
||||
'model' => new FormTestModel,
|
||||
'arrayName' => 'array',
|
||||
'fields' => [
|
||||
'trigger' => [
|
||||
'type' => 'checkboxlist',
|
||||
'options' => [
|
||||
'1' => 'Value One'
|
||||
]
|
||||
],
|
||||
'triggered' => [
|
||||
'type' => 'text',
|
||||
'trigger' => [
|
||||
'field' => 'trigger[]',
|
||||
'action' => 'show',
|
||||
'condition' => 'value[1]'
|
||||
]
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
$form->render();
|
||||
|
||||
$attributes = $form->getField('triggered')->getAttributes('container', false);
|
||||
$this->assertEquals('[name="array[trigger][]"]', array_get($attributes, 'data-trigger'));
|
||||
}
|
||||
|
||||
public function testOptionsGeneration()
|
||||
{
|
||||
$form = new Form(null, [
|
||||
'model' => new FormTestModel,
|
||||
'arrayName' => 'array',
|
||||
'fields' => [
|
||||
'static_method_options' => [
|
||||
'type' => 'dropdown',
|
||||
'options' => 'FormHelper::staticMethodOptions',
|
||||
'expect' => ['static', 'method'],
|
||||
],
|
||||
'callable_options' => [
|
||||
'type' => 'dropdown',
|
||||
'options' => [\FormHelper::class, 'staticMethodOptions'],
|
||||
'expect' => ['static', 'method'],
|
||||
],
|
||||
'collection_options' => [
|
||||
'type' => 'dropdown',
|
||||
'options' => 'collectionOptions',
|
||||
'expect' => collect(['collection', 'options']),
|
||||
],
|
||||
'model_method_options' => [
|
||||
'type' => 'dropdown',
|
||||
'options' => 'modelCustomOptionsMethod',
|
||||
'expect' => ['model', 'custom', 'options'],
|
||||
],
|
||||
'defined_options' => [
|
||||
'type' => 'dropdown',
|
||||
'options' => ['value1', 'value2'],
|
||||
'expect' => ['value1', 'value2'],
|
||||
],
|
||||
'defined_options_key_value' => [
|
||||
'type' => 'dropdown',
|
||||
'options' => [
|
||||
'key1' => 'value1',
|
||||
'key2' => 'value2',
|
||||
],
|
||||
'expect' => [
|
||||
'key1' => 'value1',
|
||||
'key2' => 'value2',
|
||||
],
|
||||
],
|
||||
'field_name_on_model_options_method' => [
|
||||
'type' => 'dropdown',
|
||||
'expect' => ['model', 'field name', 'options method'],
|
||||
],
|
||||
'get_dropdown_options_method' => [
|
||||
'type' => 'dropdown',
|
||||
'expect' => ['dropdown', 'options'],
|
||||
],
|
||||
]
|
||||
]);
|
||||
|
||||
$form->render();
|
||||
|
||||
foreach ($form->getFields() as $name => $field) {
|
||||
$this->assertEquals($field->options(), $field->config['expect']);
|
||||
}
|
||||
}
|
||||
|
||||
protected function restrictedFormFixture(bool $singlePermission = false)
|
||||
{
|
||||
return new Form(null, [
|
||||
'model' => new FormTestModel,
|
||||
'arrayName' => 'array',
|
||||
'fields' => [
|
||||
'testField' => [
|
||||
'type' => 'text',
|
||||
'label' => 'Test 1'
|
||||
],
|
||||
'testRestricted' => [
|
||||
'type' => 'text',
|
||||
'label' => 'Test 2',
|
||||
'permissions' => ($singlePermission) ? 'test.access_field' : [
|
||||
'test.access_field'
|
||||
]
|
||||
]
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
class FormHelper
|
||||
{
|
||||
public static function staticMethodOptions()
|
||||
{
|
||||
return ['static', 'method'];
|
||||
}
|
||||
}
|
||||
}
|
||||
126
modules/backend/tests/widgets/ListColumnEscapingTest.php
Normal file
126
modules/backend/tests/widgets/ListColumnEscapingTest.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace Backend\Tests\Widgets;
|
||||
|
||||
use Backend\Models\User;
|
||||
use Backend\Widgets\Lists;
|
||||
use DOMDocument;
|
||||
use System\Tests\Bootstrap\PluginTestCase;
|
||||
|
||||
/**
|
||||
* The list body partial renders column values raw (`<?= $this->getColumnValue(...) ?>`), so
|
||||
* every column type that emits a record value into markup has to escape it itself.
|
||||
*
|
||||
* Regression coverage for GHSA-7mpf-4465-7fc2, where the image column interpolated an
|
||||
* unescaped URL into a single-quoted `src` attribute.
|
||||
*/
|
||||
class ListColumnEscapingTest extends PluginTestCase
|
||||
{
|
||||
/**
|
||||
* Breaks out of a single-quoted attribute without needing whitespace: `/` is legal in a
|
||||
* URL path and is also legal after a quoted attribute value, so this survives
|
||||
* FILTER_VALIDATE_URL and still parses as a live event handler.
|
||||
*/
|
||||
protected const PAYLOAD = "http://example.com/a.jpg'/onerror='window.pwned=1";
|
||||
|
||||
/** Column types that render a free-form record value into markup. */
|
||||
public static function columnTypeProvider(): array
|
||||
{
|
||||
return [
|
||||
'text' => ['text'],
|
||||
'number' => ['number'],
|
||||
'image' => ['image'],
|
||||
'colorpicker' => ['colorpicker'],
|
||||
'switch' => ['switch'],
|
||||
];
|
||||
}
|
||||
|
||||
protected function renderColumn(string $type, $value): string
|
||||
{
|
||||
$record = new User;
|
||||
$record->email = $value;
|
||||
|
||||
$list = new Lists(null, [
|
||||
'model' => new User,
|
||||
'arrayName' => 'array',
|
||||
'columns' => ['email' => ['type' => $type, 'label' => 'Email']],
|
||||
]);
|
||||
|
||||
$list->getColumns();
|
||||
|
||||
return (string) $list->getColumnValue($record, $list->getColumn('email'));
|
||||
}
|
||||
|
||||
/** Returns every attribute name present in the rendered markup. */
|
||||
protected function attributesIn(string $html): array
|
||||
{
|
||||
$doc = new DOMDocument;
|
||||
libxml_use_internal_errors(true);
|
||||
$doc->loadHTML('<html><body>' . $html . '</body></html>');
|
||||
libxml_clear_errors();
|
||||
|
||||
$names = [];
|
||||
foreach ($doc->getElementsByTagName('*') as $element) {
|
||||
foreach ($element->attributes as $attribute) {
|
||||
$names[] = strtolower($attribute->name);
|
||||
}
|
||||
}
|
||||
|
||||
return $names;
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider columnTypeProvider
|
||||
*/
|
||||
public function testAColumnValueCannotInjectAnAttribute(string $type): void
|
||||
{
|
||||
$attributes = $this->attributesIn($this->renderColumn($type, self::PAYLOAD));
|
||||
|
||||
$handlers = array_filter($attributes, fn ($name) => str_starts_with($name, 'on'));
|
||||
|
||||
$this->assertSame([], array_values($handlers), "The {$type} column emitted an event handler attribute");
|
||||
}
|
||||
|
||||
/** The reported case, asserted on the markup rather than only on the parse result. */
|
||||
public function testTheImageColumnEscapesTheUrl(): void
|
||||
{
|
||||
$html = $this->renderColumn('image', self::PAYLOAD);
|
||||
|
||||
$this->assertStringNotContainsString("onerror='", $html);
|
||||
$this->assertStringContainsString(''/onerror=', $html, 'The quote must be entity encoded');
|
||||
$this->assertSame(['src', 'width', 'height'], $this->attributesIn($html));
|
||||
}
|
||||
|
||||
/** Config-supplied dimensions land in attributes too, so they are escaped as well. */
|
||||
public function testTheImageColumnEscapesItsDimensions(): void
|
||||
{
|
||||
$record = new User;
|
||||
$record->email = 'http://example.com/a.jpg';
|
||||
|
||||
$list = new Lists(null, [
|
||||
'model' => new User,
|
||||
'arrayName' => 'array',
|
||||
'columns' => [
|
||||
'email' => [
|
||||
'type' => 'image',
|
||||
'label' => 'Email',
|
||||
'width' => "50'/onerror='window.pwned=1",
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$list->getColumns();
|
||||
$html = (string) $list->getColumnValue($record, $list->getColumn('email'));
|
||||
|
||||
$this->assertSame(['src', 'width', 'height'], $this->attributesIn($html));
|
||||
}
|
||||
|
||||
/** Nothing regressed: an ordinary value still renders as a usable image tag. */
|
||||
public function testAnOrdinaryImageUrlStillRenders(): void
|
||||
{
|
||||
$html = $this->renderColumn('image', 'http://example.com/a.jpg');
|
||||
|
||||
$this->assertStringContainsString('http://example.com/a.jpg', $html);
|
||||
$this->assertSame(['src', 'width', 'height'], $this->attributesIn($html));
|
||||
}
|
||||
}
|
||||
136
modules/backend/tests/widgets/ListsSortableTest.php
Normal file
136
modules/backend/tests/widgets/ListsSortableTest.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace Backend\Tests\Widgets;
|
||||
|
||||
use System\Tests\Bootstrap\PluginTestCase;
|
||||
use Winter\Storm\Exception\ApplicationException;
|
||||
use Backend\Tests\Fixtures\Models\UserFixture;
|
||||
use Backend\Tests\Fixtures\Models\SortableFixture;
|
||||
use Backend\Widgets\Lists;
|
||||
use Illuminate\Http\Request as HttpRequest;
|
||||
|
||||
class ListsSortableTest extends PluginTestCase
|
||||
{
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
SortableFixture::migrateUp();
|
||||
|
||||
$this->actingAs((new UserFixture)->asSuperUser());
|
||||
}
|
||||
|
||||
public function tearDown(): void
|
||||
{
|
||||
SortableFixture::migrateDown();
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
protected function makeList(array $overrides = []): Lists
|
||||
{
|
||||
return new Lists(null, array_merge([
|
||||
'model' => new SortableFixture,
|
||||
'alias' => 'testlist',
|
||||
'arrayName' => 'array',
|
||||
'sortable' => true,
|
||||
'columns' => [
|
||||
'name' => ['type' => 'text', 'label' => 'Name'],
|
||||
'label' => ['type' => 'text', 'label' => 'Label'],
|
||||
],
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
protected function seedRecords(): array
|
||||
{
|
||||
$records = [];
|
||||
foreach (['Alpha', 'Bravo', 'Charlie'] as $i => $name) {
|
||||
$records[] = SortableFixture::create([
|
||||
'name' => strtolower($name),
|
||||
'label' => $name,
|
||||
'sort_order' => $i + 1,
|
||||
]);
|
||||
}
|
||||
return $records;
|
||||
}
|
||||
|
||||
protected function postRequest(array $data): void
|
||||
{
|
||||
$request = HttpRequest::create('/', 'POST', $data);
|
||||
$this->app->instance('request', $request);
|
||||
\Request::swap($request);
|
||||
}
|
||||
|
||||
public function testSortableDisablesPaginationAndColumnSorting()
|
||||
{
|
||||
$list = $this->makeList();
|
||||
$list->render();
|
||||
|
||||
$this->assertFalse($list->showPagination);
|
||||
// With every column forced non-sortable, no sort column is resolved.
|
||||
$this->assertFalse($list->getSortColumn());
|
||||
|
||||
foreach ($list->getColumns() as $column) {
|
||||
$this->assertFalse($column->sortable, "Column {$column->columnName} should not be sortable");
|
||||
}
|
||||
}
|
||||
|
||||
public function testSortableAddsDragHandleToColumnTotal()
|
||||
{
|
||||
$sortable = $this->makeList();
|
||||
$plain = $this->makeList(['sortable' => false]);
|
||||
|
||||
$method = new \ReflectionMethod(Lists::class, 'getTotalColumns');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$this->assertSame(
|
||||
$method->invoke($plain) + 1,
|
||||
$method->invoke($sortable),
|
||||
'Sortable list should reserve one extra column for the drag handle'
|
||||
);
|
||||
}
|
||||
|
||||
public function testOnReorderGeneratesSequentialOrdersServerSide()
|
||||
{
|
||||
$records = $this->seedRecords();
|
||||
$ids = [$records[2]->id, $records[0]->id, $records[1]->id];
|
||||
|
||||
$list = $this->makeList();
|
||||
|
||||
$captured = null;
|
||||
$list->bindEvent('list.reorder', function ($eventIds, $eventOrders) use (&$captured) {
|
||||
$captured = [$eventIds, $eventOrders];
|
||||
});
|
||||
|
||||
// The client sends only the record ids in their new order; the server assigns the
|
||||
// sort order values 1..N by position.
|
||||
$this->postRequest(['record_ids' => $ids]);
|
||||
$list->onReorder();
|
||||
|
||||
$this->assertNotNull($captured, 'list.reorder event should have fired');
|
||||
$this->assertSame(array_map('strval', $ids), array_map('strval', $captured[0]));
|
||||
$this->assertSame([1, 2, 3], $captured[1]);
|
||||
}
|
||||
|
||||
public function testOnReorderRejectsRecordsOutsideQueryScope()
|
||||
{
|
||||
$records = $this->seedRecords();
|
||||
|
||||
$list = $this->makeList();
|
||||
|
||||
// 99999 is not a seeded record id.
|
||||
$this->postRequest(['record_ids' => [$records[0]->id, 99999]]);
|
||||
|
||||
$this->expectException(ApplicationException::class);
|
||||
$list->onReorder();
|
||||
}
|
||||
|
||||
public function testOnReorderThrowsWhenNotSortable()
|
||||
{
|
||||
$list = $this->makeList(['sortable' => false]);
|
||||
$this->postRequest(['record_ids' => [1]]);
|
||||
|
||||
$this->expectException(ApplicationException::class);
|
||||
$list->onReorder();
|
||||
}
|
||||
}
|
||||
143
modules/backend/tests/widgets/ListsTest.php
Normal file
143
modules/backend/tests/widgets/ListsTest.php
Normal file
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
namespace Backend\Tests\Widgets;
|
||||
|
||||
use System\Tests\Bootstrap\PluginTestCase;
|
||||
use Winter\Storm\Exception\ApplicationException;
|
||||
use Backend\Tests\Fixtures\Models\UserFixture;
|
||||
use Backend\Models\User;
|
||||
use Backend\Widgets\Lists;
|
||||
|
||||
class ListsTest extends PluginTestCase
|
||||
{
|
||||
public function testRestrictedColumnWithUserWithNoPermissions()
|
||||
{
|
||||
$user = new UserFixture;
|
||||
$this->actingAs($user);
|
||||
|
||||
$list = $this->restrictedListsFixture();
|
||||
$list->render();
|
||||
|
||||
$this->assertNotNull($list->getColumn('id'));
|
||||
|
||||
// Expect an exception
|
||||
$this->expectException(ApplicationException::class);
|
||||
$this->expectExceptionMessage('No definition for column email');
|
||||
$column = $list->getColumn('email');
|
||||
}
|
||||
|
||||
public function testRestrictedColumnWithUserWithWrongPermissions()
|
||||
{
|
||||
$user = new UserFixture;
|
||||
$this->actingAs($user->withPermission('test.wrong_permission', true));
|
||||
|
||||
$list = $this->restrictedListsFixture();
|
||||
$list->render();
|
||||
|
||||
$this->assertNotNull($list->getColumn('id'));
|
||||
|
||||
// Expect an exception
|
||||
$this->expectException(ApplicationException::class);
|
||||
$this->expectExceptionMessage('No definition for column email');
|
||||
$column = $list->getColumn('email');
|
||||
}
|
||||
|
||||
public function testRestrictedColumnWithUserWithRightPermissions()
|
||||
{
|
||||
$user = new UserFixture;
|
||||
$this->actingAs($user->withPermission('test.access_field', true));
|
||||
|
||||
$list = $this->restrictedListsFixture();
|
||||
$list->render();
|
||||
|
||||
$this->assertNotNull($list->getColumn('id'));
|
||||
$this->assertNotNull($list->getColumn('email'));
|
||||
}
|
||||
|
||||
public function testRestrictedColumnWithUserWithRightWildcardPermissions()
|
||||
{
|
||||
$user = new UserFixture;
|
||||
$this->actingAs($user->withPermission('test.access_field', true));
|
||||
|
||||
$list = new Lists(null, [
|
||||
'model' => new User,
|
||||
'arrayName' => 'array',
|
||||
'columns' => [
|
||||
'id' => [
|
||||
'type' => 'text',
|
||||
'label' => 'ID'
|
||||
],
|
||||
'email' => [
|
||||
'type' => 'text',
|
||||
'label' => 'Email',
|
||||
'permission' => 'test.*'
|
||||
]
|
||||
]
|
||||
]);
|
||||
$list->render();
|
||||
|
||||
$this->assertNotNull($list->getColumn('id'));
|
||||
$this->assertNotNull($list->getColumn('email'));
|
||||
}
|
||||
|
||||
public function testRestrictedColumnWithSuperuser()
|
||||
{
|
||||
$user = new UserFixture;
|
||||
$this->actingAs($user->asSuperUser());
|
||||
|
||||
$list = $this->restrictedListsFixture();
|
||||
$list->render();
|
||||
|
||||
$this->assertNotNull($list->getColumn('id'));
|
||||
$this->assertNotNull($list->getColumn('email'));
|
||||
}
|
||||
|
||||
public function testRestrictedColumnSinglePermissionWithUserWithWrongPermissions()
|
||||
{
|
||||
$user = new UserFixture;
|
||||
$this->actingAs($user->withPermission('test.wrong_permission', true));
|
||||
|
||||
$list = $this->restrictedListsFixture(true);
|
||||
$list->render();
|
||||
|
||||
$this->assertNotNull($list->getColumn('id'));
|
||||
|
||||
// Expect an exception
|
||||
$this->expectException(ApplicationException::class);
|
||||
$this->expectExceptionMessage('No definition for column email');
|
||||
$column = $list->getColumn('email');
|
||||
}
|
||||
|
||||
public function testRestrictedColumnSinglePermissionWithUserWithRightPermissions()
|
||||
{
|
||||
$user = new UserFixture;
|
||||
$this->actingAs($user->withPermission('test.access_field', true));
|
||||
|
||||
$list = $this->restrictedListsFixture(true);
|
||||
$list->render();
|
||||
|
||||
$this->assertNotNull($list->getColumn('id'));
|
||||
$this->assertNotNull($list->getColumn('email'));
|
||||
}
|
||||
|
||||
protected function restrictedListsFixture(bool $singlePermission = false)
|
||||
{
|
||||
return new Lists(null, [
|
||||
'model' => new User,
|
||||
'arrayName' => 'array',
|
||||
'columns' => [
|
||||
'id' => [
|
||||
'type' => 'text',
|
||||
'label' => 'ID'
|
||||
],
|
||||
'email' => [
|
||||
'type' => 'text',
|
||||
'label' => 'Email',
|
||||
'permissions' => ($singlePermission) ? 'test.access_field' : [
|
||||
'test.access_field'
|
||||
]
|
||||
]
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
189
modules/backend/tests/widgets/TableSearchEscapingTest.php
Normal file
189
modules/backend/tests/widgets/TableSearchEscapingTest.php
Normal file
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
namespace Backend\Tests\Widgets;
|
||||
|
||||
use Backend\Widgets\Table;
|
||||
use System\Tests\Bootstrap\PluginTestCase;
|
||||
|
||||
/**
|
||||
* Regression coverage for GHSA-hq84-x37p-j6q5.
|
||||
*
|
||||
* The Table widget partial renders the request's `search` value inside a
|
||||
* <script type="text/template"> block. <script> is an HTML raw-text context, so the
|
||||
* surrounding value="..." quoting is not a boundary: a literal </script> in the query
|
||||
* string terminated the template early and injected attacker markup into the backend
|
||||
* document. The value must therefore be HTML-encoded on output.
|
||||
*
|
||||
* @see modules/backend/widgets/table/partials/_table.php
|
||||
*/
|
||||
class TableSearchEscapingTest extends PluginTestCase
|
||||
{
|
||||
/**
|
||||
* The partial emits exactly two template blocks: [data-table-toolbar] and
|
||||
* [data-table-toolbar-search]. Any extra closing tag in the output means a payload
|
||||
* introduced a raw-text terminator of its own.
|
||||
*/
|
||||
const EXPECTED_SCRIPT_CLOSERS = 2;
|
||||
|
||||
const SEARCH_TEMPLATE_OPENER = '<script type="text/template" data-table-toolbar-search>';
|
||||
|
||||
/**
|
||||
* Sets the ?search= value seen by the get() helper, which reads Request::query().
|
||||
*/
|
||||
protected function setSearchQuery(string $value): void
|
||||
{
|
||||
$this->app['request']->query->set('search', $value);
|
||||
}
|
||||
|
||||
protected function renderTable(array $config = []): string
|
||||
{
|
||||
$table = new Table(null, array_merge([
|
||||
'dataSource' => 'client',
|
||||
'columns' => [
|
||||
'title' => ['title' => 'Title'],
|
||||
],
|
||||
], $config));
|
||||
|
||||
return $table->render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns everything rendered after the search template's closing tag. On a correctly
|
||||
* escaped output this is only the partial's own trailing markup.
|
||||
*/
|
||||
protected function markupAfterSearchTemplate(string $html): string
|
||||
{
|
||||
$openerPos = strpos($html, self::SEARCH_TEMPLATE_OPENER);
|
||||
$this->assertNotFalse($openerPos, 'Search template block should be present');
|
||||
|
||||
$afterOpener = substr($html, $openerPos + strlen(self::SEARCH_TEMPLATE_OPENER));
|
||||
|
||||
// End tags are case-insensitive and may carry whitespace before the ">", so a
|
||||
// literal "</script>" search would miss </ScRiPt> and "</script >" terminators
|
||||
// and report a clean result for payloads that do in fact break out.
|
||||
$this->assertSame(
|
||||
1,
|
||||
preg_match('~</script\s*>~i', $afterOpener, $m, PREG_OFFSET_CAPTURE),
|
||||
'Search template should be closed'
|
||||
);
|
||||
|
||||
return substr($afterOpener, $m[0][1] + strlen($m[0][0]));
|
||||
}
|
||||
|
||||
public static function rawTextTerminatorProvider(): array
|
||||
{
|
||||
return [
|
||||
'plain closing tag' => ['</script><meta name="probe-plain">'],
|
||||
'mixed case' => ['</ScRiPt><meta name="probe-case">'],
|
||||
'trailing space' => ['</script ><meta name="probe-space">'],
|
||||
'trailing tab' => ["</script\t><meta name=\"probe-tab\">"],
|
||||
'trailing newline' => ["</script\n><meta name=\"probe-newline\">"],
|
||||
'attribute breakout' => ['"><img src=x onerror=alert(1)>'],
|
||||
'script element' => ['</script><script>alert(1)</script>'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider rawTextTerminatorProvider
|
||||
*/
|
||||
public function testSearchValueCannotTerminateTheScriptTemplate(string $payload)
|
||||
{
|
||||
$this->setSearchQuery($payload);
|
||||
|
||||
$html = $this->renderTable();
|
||||
|
||||
$this->assertStringNotContainsString(
|
||||
$payload,
|
||||
$html,
|
||||
'The raw payload must never be reflected verbatim'
|
||||
);
|
||||
|
||||
$this->assertSame(
|
||||
self::EXPECTED_SCRIPT_CLOSERS,
|
||||
substr_count($html, '</script>'),
|
||||
'Payload introduced an extra raw-text terminator into the output'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider rawTextTerminatorProvider
|
||||
*/
|
||||
public function testPayloadCannotEscapeIntoDocumentMarkup(string $payload)
|
||||
{
|
||||
$this->setSearchQuery($payload);
|
||||
|
||||
$escaped = $this->markupAfterSearchTemplate($this->renderTable());
|
||||
|
||||
$this->assertStringNotContainsString('probe-', $escaped, 'Marker escaped the template');
|
||||
$this->assertStringNotContainsString('<img', $escaped, 'Image element escaped the template');
|
||||
$this->assertStringNotContainsString('alert(1)', $escaped, 'Script payload escaped the template');
|
||||
}
|
||||
|
||||
/**
|
||||
* The template is emitted unconditionally by the partial -- it does not depend on the
|
||||
* `searching` option -- so the sink must be safe in both states. `searching` defaults
|
||||
* to false, which was the configuration most affected instances shipped with.
|
||||
*/
|
||||
public function testEscapingAppliesRegardlessOfSearchingOption()
|
||||
{
|
||||
foreach ([true, false] as $searching) {
|
||||
$this->setSearchQuery('</script><meta name="probe-toggle">');
|
||||
|
||||
$html = $this->renderTable(['searching' => $searching]);
|
||||
|
||||
$this->assertStringNotContainsString(
|
||||
'</script><meta',
|
||||
$html,
|
||||
'Sink must be escaped with searching=' . var_export($searching, true)
|
||||
);
|
||||
$this->assertSame(
|
||||
self::EXPECTED_SCRIPT_CLOSERS,
|
||||
substr_count($html, '</script>'),
|
||||
'Unexpected terminator with searching=' . var_export($searching, true)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function testAngleBracketsAndQuotesAreEncoded()
|
||||
{
|
||||
$this->setSearchQuery('<>"\'&');
|
||||
|
||||
$html = $this->renderTable();
|
||||
|
||||
$this->assertStringContainsString('value="<>"'&"', $html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Guards against a fix that escapes but mangles ordinary input.
|
||||
*/
|
||||
public function testOrdinarySearchTextIsPreserved()
|
||||
{
|
||||
$this->setSearchQuery('hello world');
|
||||
|
||||
$this->assertStringContainsString('value="hello world"', $this->renderTable());
|
||||
}
|
||||
|
||||
/**
|
||||
* Guards against a fix that breaks non-ASCII search terms.
|
||||
*/
|
||||
public function testUnicodeSearchTextIsPreserved()
|
||||
{
|
||||
$this->setSearchQuery('héllo 世界 😀');
|
||||
|
||||
$this->assertStringContainsString('value="héllo 世界 😀"', $this->renderTable());
|
||||
}
|
||||
|
||||
/**
|
||||
* Control: a payload with no raw-text terminator was never able to break out, so it
|
||||
* must not be counted as evidence that escaping works. If this ever fails, the tests
|
||||
* above are measuring something other than the raw-text boundary.
|
||||
*/
|
||||
public function testEscapedSlashControlNeverEscapedTheTemplate()
|
||||
{
|
||||
$this->setSearchQuery('<\\/script><meta name="probe-control">');
|
||||
|
||||
$escaped = $this->markupAfterSearchTemplate($this->renderTable());
|
||||
|
||||
$this->assertStringNotContainsString('probe-control', $escaped);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user