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:
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