feat: VivesPOS landing on Winter CMS 1.2 — theme + plugin + Dockerfile
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:
2026-08-21 19:29:00 -06:00
commit 1f72193a64
3266 changed files with 531480 additions and 0 deletions

View File

@@ -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)
);
}
}

View File

@@ -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'
);
}
}

View 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]]);
}
}

View 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');
}
}

View 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');
}
}

View 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];
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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();
}
}

View 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);
}
}

View 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);
}
}

View File

@@ -0,0 +1,5 @@
/* Comments
=require js/file1.js
=require js/file2.js
*/

View File

@@ -0,0 +1 @@
console.log('Test File 1');

View File

@@ -0,0 +1 @@
console.log('Test File 2');

View 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');
}
}

View 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');
}
}

View 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;
}
}

View File

@@ -0,0 +1 @@
File one contents

View File

@@ -0,0 +1 @@
FILE TWO CONTENTS

View 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());
}
}

View 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);
}
}

View 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();
}
}

View 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');
}
}

View 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);
}
}
}

View 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);
}
}
}

View 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);
}
}
}

View 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.'
);
}
}

View 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);
}
}

View 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);
}
}

View 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'));
}
}

View 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],
]);
}
}

View 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);
}
}
}

View 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'];
}
}
}

View 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('&#039;/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));
}
}

View 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();
}
}

View 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'
]
]
]
]);
}
}

View 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="&lt;&gt;&quot;&#039;&amp;"', $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);
}
}