feat: VivesPOS landing on Winter CMS 1.2 — theme + plugin + Dockerfile
Some checks are pending
Module sub-split / Sub-split (push) Waiting to run
Some checks are pending
Module sub-split / Sub-split (push) Waiting to run
- Base: wintercms/winter branch 1.2 (full framework) - Theme vivespos: Canvas 7 + Bootstrap 5 CDN, custom CSS - Layout: deferred GTM/GA4 tracking, JSON-LD SoftwareApplication - Partials: hero (offline-first), features, modes (offline/nube toggle), screenshots, pricing (3 planes), comparison, FAQ, CTA - Plugin VivesPOS.Site with ContactForm - Dockerfile: PHP 8.2 Apache, port 80, healthcheck - Added winter/wn-pages, blog, sitemap, seo plugins - Active theme set to vivespos
This commit is contained in:
138
modules/backend/tests/models/BrandSettingTest.php
Normal file
138
modules/backend/tests/models/BrandSettingTest.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace Backend\Tests\Models;
|
||||
|
||||
use Backend\Models\BrandSetting;
|
||||
use System\Tests\Bootstrap\PluginTestCase;
|
||||
|
||||
class BrandSettingTest extends PluginTestCase
|
||||
{
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Reset the cached instance so each test starts fresh
|
||||
\System\Behaviors\SettingsModel::clearInternalCache();
|
||||
}
|
||||
|
||||
public function tearDown(): void
|
||||
{
|
||||
// Clean up the settings record
|
||||
\Illuminate\Support\Facades\Cache::forget(BrandSetting::instance()->cacheKey);
|
||||
BrandSetting::instance()->resetDefault();
|
||||
\System\Behaviors\SettingsModel::clearInternalCache();
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that renderCss output does not contain script tags even when
|
||||
* malicious CSS using LESS escape syntax is stored in the database.
|
||||
*/
|
||||
public function testRenderCssStripsScriptTags()
|
||||
{
|
||||
$maliciousCss = '.x { content: ~"</style><script>alert(1)</script><style>"; }';
|
||||
|
||||
BrandSetting::set('custom_css', $maliciousCss);
|
||||
|
||||
\System\Behaviors\SettingsModel::clearInternalCache();
|
||||
\Illuminate\Support\Facades\Cache::forget(BrandSetting::instance()->cacheKey);
|
||||
|
||||
$renderedCss = BrandSetting::renderCss();
|
||||
|
||||
$this->assertStringNotContainsString('<script>', $renderedCss);
|
||||
$this->assertStringNotContainsString('</script>', $renderedCss);
|
||||
$this->assertStringNotContainsString('</style>', $renderedCss);
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression for GHSA-5cwr-5jxg-pcf6. renderCss() caches the raw compiler
|
||||
* output, so sanitizing only the cache-miss return leaves every later cache
|
||||
* hit unsanitized. The first render primes the cache; the second is the one
|
||||
* that used to emit active markup into the backend <style> block.
|
||||
*/
|
||||
public function testRenderCssStripsScriptTagsOnCacheHit()
|
||||
{
|
||||
$maliciousCss = '.x { content: ~"</style><script>alert(1)</script><style>"; }';
|
||||
|
||||
BrandSetting::set('custom_css', $maliciousCss);
|
||||
|
||||
\System\Behaviors\SettingsModel::clearInternalCache();
|
||||
\Illuminate\Support\Facades\Cache::forget(BrandSetting::instance()->cacheKey);
|
||||
|
||||
// Cache miss, primes the cache
|
||||
BrandSetting::renderCss();
|
||||
|
||||
// Cache hit
|
||||
$renderedCss = BrandSetting::renderCss();
|
||||
|
||||
$this->assertStringNotContainsString('<script>', $renderedCss);
|
||||
$this->assertStringNotContainsString('</script>', $renderedCss);
|
||||
$this->assertStringNotContainsString('</style>', $renderedCss);
|
||||
}
|
||||
|
||||
/**
|
||||
* A cache entry poisoned before GHSA-5cwr-5jxg-pcf6 was patched is not
|
||||
* cleared by upgrading, so it must still be sanitized when read back.
|
||||
*/
|
||||
public function testRenderCssStripsScriptTagsFromExistingCacheEntry()
|
||||
{
|
||||
\Illuminate\Support\Facades\Cache::forever(
|
||||
BrandSetting::instance()->cacheKey,
|
||||
'.x{content:</style><script>alert(1)</script><style>}'
|
||||
);
|
||||
|
||||
$renderedCss = BrandSetting::renderCss();
|
||||
|
||||
$this->assertStringNotContainsString('<script>', $renderedCss);
|
||||
$this->assertStringNotContainsString('</script>', $renderedCss);
|
||||
$this->assertStringNotContainsString('</style>', $renderedCss);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that normal CSS content is preserved through renderCss, on both the
|
||||
* cache miss and the cache hit that follows it.
|
||||
*/
|
||||
public function testRenderCssPreservesNormalCss()
|
||||
{
|
||||
$normalCss = '.my-class { color: red; font-size: 14px; }';
|
||||
|
||||
BrandSetting::set('custom_css', $normalCss);
|
||||
|
||||
\System\Behaviors\SettingsModel::clearInternalCache();
|
||||
\Illuminate\Support\Facades\Cache::forget(BrandSetting::instance()->cacheKey);
|
||||
|
||||
$renderedCss = BrandSetting::renderCss();
|
||||
|
||||
$this->assertStringContainsString('color', $renderedCss);
|
||||
$this->assertStringContainsString('font-size', $renderedCss);
|
||||
$this->assertDoesNotMatchRegularExpression('/<[a-z\/!]/', $renderedCss);
|
||||
|
||||
// Sanitizing the cache hit must not alter legitimate CSS
|
||||
$this->assertEquals($renderedCss, BrandSetting::renderCss());
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression for GHSA-58fp-mcx6-7qf9. A user-supplied `@import (inline)`
|
||||
* directive in `custom_css` must not be able to disclose server files.
|
||||
*/
|
||||
public function testRenderCssBlocksImportAttack()
|
||||
{
|
||||
$tmpSecret = tempnam(sys_get_temp_dir(), 'brandsetting-leak-canary-');
|
||||
file_put_contents($tmpSecret, "APP_KEY=do-not-leak-via-brandsetting\n");
|
||||
|
||||
try {
|
||||
BrandSetting::set('custom_css', '@import (inline) "' . $tmpSecret . '";');
|
||||
|
||||
\System\Behaviors\SettingsModel::clearInternalCache();
|
||||
\Illuminate\Support\Facades\Cache::forget(BrandSetting::instance()->cacheKey);
|
||||
|
||||
$renderedCss = BrandSetting::renderCss();
|
||||
|
||||
$this->assertStringNotContainsString('APP_KEY', $renderedCss);
|
||||
$this->assertStringNotContainsString('do-not-leak-via-brandsetting', $renderedCss);
|
||||
} finally {
|
||||
@unlink($tmpSecret);
|
||||
}
|
||||
}
|
||||
}
|
||||
138
modules/backend/tests/models/EditorSettingTest.php
Normal file
138
modules/backend/tests/models/EditorSettingTest.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace Backend\Tests\Models;
|
||||
|
||||
use Backend\Models\EditorSetting;
|
||||
use System\Tests\Bootstrap\PluginTestCase;
|
||||
|
||||
class EditorSettingTest extends PluginTestCase
|
||||
{
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Reset the cached instance so each test starts fresh
|
||||
\System\Behaviors\SettingsModel::clearInternalCache();
|
||||
}
|
||||
|
||||
public function tearDown(): void
|
||||
{
|
||||
// Clean up the settings record
|
||||
\Illuminate\Support\Facades\Cache::forget(EditorSetting::instance()->cacheKey);
|
||||
EditorSetting::instance()->resetDefault();
|
||||
\System\Behaviors\SettingsModel::clearInternalCache();
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that renderCss output does not contain script tags even when
|
||||
* malicious CSS using LESS escape syntax is stored in the database.
|
||||
*/
|
||||
public function testRenderCssStripsScriptTags()
|
||||
{
|
||||
$maliciousStyles = '.x { content: ~"</style><script>alert(1)</script><style>"; }';
|
||||
|
||||
EditorSetting::set('html_custom_styles', $maliciousStyles);
|
||||
|
||||
\System\Behaviors\SettingsModel::clearInternalCache();
|
||||
\Illuminate\Support\Facades\Cache::forget(EditorSetting::instance()->cacheKey);
|
||||
|
||||
$renderedCss = EditorSetting::renderCss();
|
||||
|
||||
$this->assertStringNotContainsString('<script>', $renderedCss);
|
||||
$this->assertStringNotContainsString('</script>', $renderedCss);
|
||||
$this->assertStringNotContainsString('</style>', $renderedCss);
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression for GHSA-5cwr-5jxg-pcf6. renderCss() caches the raw compiler
|
||||
* output, so sanitizing only the cache-miss return leaves every later cache
|
||||
* hit unsanitized. The first render primes the cache; the second is the one
|
||||
* that used to emit active markup into the backend <style> block.
|
||||
*/
|
||||
public function testRenderCssStripsScriptTagsOnCacheHit()
|
||||
{
|
||||
$maliciousStyles = '.x { content: ~"</style><script>alert(1)</script><style>"; }';
|
||||
|
||||
EditorSetting::set('html_custom_styles', $maliciousStyles);
|
||||
|
||||
\System\Behaviors\SettingsModel::clearInternalCache();
|
||||
\Illuminate\Support\Facades\Cache::forget(EditorSetting::instance()->cacheKey);
|
||||
|
||||
// Cache miss, primes the cache
|
||||
EditorSetting::renderCss();
|
||||
|
||||
// Cache hit
|
||||
$renderedCss = EditorSetting::renderCss();
|
||||
|
||||
$this->assertStringNotContainsString('<script>', $renderedCss);
|
||||
$this->assertStringNotContainsString('</script>', $renderedCss);
|
||||
$this->assertStringNotContainsString('</style>', $renderedCss);
|
||||
}
|
||||
|
||||
/**
|
||||
* A cache entry poisoned before GHSA-5cwr-5jxg-pcf6 was patched is not
|
||||
* cleared by upgrading, so it must still be sanitized when read back.
|
||||
*/
|
||||
public function testRenderCssStripsScriptTagsFromExistingCacheEntry()
|
||||
{
|
||||
\Illuminate\Support\Facades\Cache::forever(
|
||||
EditorSetting::instance()->cacheKey,
|
||||
'.fr-view .x{content:</style><script>alert(1)</script><style>}'
|
||||
);
|
||||
|
||||
$renderedCss = EditorSetting::renderCss();
|
||||
|
||||
$this->assertStringNotContainsString('<script>', $renderedCss);
|
||||
$this->assertStringNotContainsString('</script>', $renderedCss);
|
||||
$this->assertStringNotContainsString('</style>', $renderedCss);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that normal CSS content is preserved through renderCss, on both the
|
||||
* cache miss and the cache hit that follows it.
|
||||
*/
|
||||
public function testRenderCssPreservesNormalCss()
|
||||
{
|
||||
$normalStyles = '.my-class { color: blue; font-weight: bold; }';
|
||||
|
||||
EditorSetting::set('html_custom_styles', $normalStyles);
|
||||
|
||||
\System\Behaviors\SettingsModel::clearInternalCache();
|
||||
\Illuminate\Support\Facades\Cache::forget(EditorSetting::instance()->cacheKey);
|
||||
|
||||
$renderedCss = EditorSetting::renderCss();
|
||||
|
||||
$this->assertStringContainsString('color', $renderedCss);
|
||||
$this->assertStringContainsString('font-weight', $renderedCss);
|
||||
$this->assertDoesNotMatchRegularExpression('/<[a-z\/!]/', $renderedCss);
|
||||
|
||||
// Sanitizing the cache hit must not alter legitimate CSS
|
||||
$this->assertEquals($renderedCss, EditorSetting::renderCss());
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression for GHSA-58fp-mcx6-7qf9. A user-supplied `@import (inline)`
|
||||
* directive in `html_custom_styles` must not be able to disclose server files.
|
||||
*/
|
||||
public function testRenderCssBlocksImportAttack()
|
||||
{
|
||||
$tmpSecret = tempnam(sys_get_temp_dir(), 'editorsetting-leak-canary-');
|
||||
file_put_contents($tmpSecret, "APP_KEY=do-not-leak-via-editorsetting\n");
|
||||
|
||||
try {
|
||||
EditorSetting::set('html_custom_styles', '@import (inline) "' . $tmpSecret . '";');
|
||||
|
||||
\System\Behaviors\SettingsModel::clearInternalCache();
|
||||
\Illuminate\Support\Facades\Cache::forget(EditorSetting::instance()->cacheKey);
|
||||
|
||||
$renderedCss = EditorSetting::renderCss();
|
||||
|
||||
$this->assertStringNotContainsString('APP_KEY', $renderedCss);
|
||||
$this->assertStringNotContainsString('do-not-leak-via-editorsetting', $renderedCss);
|
||||
} finally {
|
||||
@unlink($tmpSecret);
|
||||
}
|
||||
}
|
||||
}
|
||||
94
modules/backend/tests/models/ExportModelTest.php
Normal file
94
modules/backend/tests/models/ExportModelTest.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace Backend\Tests\Models;
|
||||
|
||||
use System\Tests\Bootstrap\TestCase;
|
||||
use Backend\Models\ExportModel;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
if (!class_exists('Model')) {
|
||||
class_alias('Winter\Storm\Database\Model', 'Model');
|
||||
}
|
||||
|
||||
class ExampleExportModel extends ExportModel
|
||||
{
|
||||
public function exportData($columns, $sessionKey = null)
|
||||
{
|
||||
return [
|
||||
[
|
||||
'foo' => 'bar',
|
||||
'bar' => 'foo',
|
||||
'foobar' => 'Hello World!',
|
||||
],
|
||||
[
|
||||
'foo' => 'bar2',
|
||||
'bar' => 'foo2',
|
||||
'foobar' => 'Hello World2!',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class ExportModelTest extends TestCase
|
||||
{
|
||||
|
||||
//
|
||||
// Tests
|
||||
//
|
||||
|
||||
public function testEncodeArrayValue()
|
||||
{
|
||||
$model = new ExampleExportModel;
|
||||
$data = ['foo', 'bar'];
|
||||
$result = self::callProtectedMethod($model, 'encodeArrayValue', [$data]);
|
||||
$this->assertEquals('foo|bar', $result);
|
||||
|
||||
$data = ['dps | heals | tank', 'paladin', 'berserker', 'gunner'];
|
||||
$result = self::callProtectedMethod($model, 'encodeArrayValue', [$data]);
|
||||
$this->assertEquals('dps \| heals \| tank|paladin|berserker|gunner', $result);
|
||||
|
||||
$data = ['art direction', 'roman empire', 'sci-fi'];
|
||||
$result = self::callProtectedMethod($model, 'encodeArrayValue', [$data, '-']);
|
||||
$this->assertEquals('art direction-roman empire-sci\-fi', $result);
|
||||
}
|
||||
|
||||
public function testDownload()
|
||||
{
|
||||
$model = new ExampleExportModel;
|
||||
|
||||
$csvName = $model->export(['foo' => 'title', 'bar' => 'title2'], []);
|
||||
|
||||
$response = $model->download($csvName);
|
||||
|
||||
$request = new Request();
|
||||
|
||||
$response->prepare($request);
|
||||
|
||||
$this->assertTrue($response->headers->has('Content-Type'), "Response is missing the Content-Type header!");
|
||||
|
||||
$contentType = $response->headers->get('Content-Type');
|
||||
$this->assertTrue(
|
||||
str_contains($contentType, 'application/csv')
|
||||
|| str_contains($contentType, 'text/plain')
|
||||
|| str_contains($contentType, 'text/csv'),
|
||||
"Content-Type is not as expected, provided: " . $contentType
|
||||
);
|
||||
|
||||
ob_start();
|
||||
$response->send();
|
||||
$output = ob_get_clean();
|
||||
|
||||
$utf8BOM = chr(239) . chr(187) . chr(191);
|
||||
|
||||
$this->assertEquals($utf8BOM . "title,title2\nbar,foo\nbar2,foo2\n", $output, "CSV is not right!");
|
||||
|
||||
$filePath = temp_path($csvName);
|
||||
|
||||
$fileGotDeleted = !is_file($filePath);
|
||||
|
||||
$this->assertTrue($fileGotDeleted, "Export-CSV doesn't get deleted.");
|
||||
if (!$fileGotDeleted) {
|
||||
unlink($filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
70
modules/backend/tests/models/ImportModelTest.php
Normal file
70
modules/backend/tests/models/ImportModelTest.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace Backend\Tests\Models;
|
||||
|
||||
use System\Tests\Bootstrap\PluginTestCase;
|
||||
use Backend\Models\ImportModel;
|
||||
use System\Models\File as FileModel;
|
||||
|
||||
if (!class_exists('Model')) {
|
||||
class_alias('Winter\Storm\Database\Model', 'Model');
|
||||
}
|
||||
|
||||
class ExampleImportModel extends ImportModel
|
||||
{
|
||||
public $rules = [];
|
||||
|
||||
public function importData($results, $sessionKey = null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
class ImportModelTest extends PluginTestCase
|
||||
{
|
||||
|
||||
//
|
||||
// Tests
|
||||
//
|
||||
|
||||
public function testDecodeArrayValue()
|
||||
{
|
||||
$model = new ExampleImportModel;
|
||||
$data = 'foo|bar';
|
||||
$result = self::callProtectedMethod($model, 'decodeArrayValue', [$data]);
|
||||
$this->assertEquals(['foo', 'bar'], $result);
|
||||
|
||||
$data = 'dps \| heals \| tank|paladin|berserker|gunner';
|
||||
$result = self::callProtectedMethod($model, 'decodeArrayValue', [$data]);
|
||||
$this->assertEquals(['dps | heals | tank', 'paladin', 'berserker', 'gunner'], $result);
|
||||
|
||||
$data = 'art direction-roman empire-sci\-fi';
|
||||
$result = self::callProtectedMethod($model, 'decodeArrayValue', [$data, '-']);
|
||||
$this->assertEquals(['art direction', 'roman empire', 'sci-fi'], $result);
|
||||
}
|
||||
|
||||
public function testGetImportFilePath()
|
||||
{
|
||||
$model = new ExampleImportModel;
|
||||
$sessionKey = uniqid('session_key', true);
|
||||
|
||||
$file1 = FileModel::create([
|
||||
'data' => base_path().'/modules/backend/tests/fixtures/reference/file1.txt',
|
||||
'is_public' => false,
|
||||
]);
|
||||
|
||||
$file2 = FileModel::create([
|
||||
'data' => base_path().'/modules/backend/tests/fixtures/reference/file2.txt',
|
||||
'is_public' => false,
|
||||
]);
|
||||
|
||||
$model->import_file()->add($file1, $sessionKey);
|
||||
$model->import_file()->add($file2, $sessionKey);
|
||||
|
||||
$this->assertEquals(
|
||||
$file2->getLocalPath(),
|
||||
$model->getImportFilePath($sessionKey),
|
||||
'ImportModel::getImportFilePath() should return the last uploaded file.'
|
||||
);
|
||||
}
|
||||
}
|
||||
480
modules/backend/tests/models/UserAuthorizationTest.php
Normal file
480
modules/backend/tests/models/UserAuthorizationTest.php
Normal file
@@ -0,0 +1,480 @@
|
||||
<?php
|
||||
|
||||
namespace Backend\Tests\Models;
|
||||
|
||||
use Backend\Facades\BackendAuth;
|
||||
use Backend\Models\User;
|
||||
use Backend\Models\UserGroup;
|
||||
use Backend\Tests\Fixtures\Models\UserFixture;
|
||||
use System\Tests\Bootstrap\PluginTestCase;
|
||||
use Winter\Storm\Auth\AuthorizationException;
|
||||
use Winter\Storm\Database\Model;
|
||||
|
||||
class UserAuthorizationTest extends PluginTestCase
|
||||
{
|
||||
protected User $targetUser;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Create a real target user in the database for operations that need it
|
||||
Model::unguard();
|
||||
$this->targetUser = User::create([
|
||||
'first_name' => 'Target',
|
||||
'last_name' => 'User',
|
||||
'login' => 'targetuser',
|
||||
'email' => 'target@test.com',
|
||||
'password' => 'TestPassword1',
|
||||
'password_confirmation' => 'TestPassword1',
|
||||
'is_activated' => true,
|
||||
'is_superuser' => false,
|
||||
]);
|
||||
Model::reguard();
|
||||
}
|
||||
|
||||
//
|
||||
// canBeManagedByUser()
|
||||
//
|
||||
|
||||
public function testCanBeManagedByUserReturnsTrueForCli(): void
|
||||
{
|
||||
// No authenticated user (CLI context)
|
||||
BackendAuth::logout();
|
||||
$this->assertTrue($this->targetUser->canBeManagedByUser());
|
||||
}
|
||||
|
||||
public function testCanBeManagedByUserReturnsFalseWithoutPermission(): void
|
||||
{
|
||||
$actor = (new UserFixture)->withPermission('backend.manage_users', false);
|
||||
$this->assertFalse($this->targetUser->canBeManagedByUser($actor));
|
||||
}
|
||||
|
||||
public function testCanBeManagedByUserReturnsTrueWithPermission(): void
|
||||
{
|
||||
$actor = (new UserFixture)->withPermission('backend.manage_users', true);
|
||||
$this->assertTrue($this->targetUser->canBeManagedByUser($actor));
|
||||
}
|
||||
|
||||
public function testCanBeManagedByUserReturnsFalseForNonSuperuserTargetingSuperuser(): void
|
||||
{
|
||||
$this->targetUser->is_superuser = true;
|
||||
$actor = (new UserFixture)->withPermission('backend.manage_users', true);
|
||||
$this->assertFalse($this->targetUser->canBeManagedByUser($actor));
|
||||
}
|
||||
|
||||
public function testCanBeManagedByUserReturnsTrueForSuperuserTargetingSuperuser(): void
|
||||
{
|
||||
$this->targetUser->is_superuser = true;
|
||||
$actor = (new UserFixture)->asSuperUser()->withPermission('backend.manage_users', true);
|
||||
$this->assertTrue($this->targetUser->canBeManagedByUser($actor));
|
||||
}
|
||||
|
||||
public function testCanBeManagedByUserChecksPreviousSuperuserStatus(): void
|
||||
{
|
||||
// Simulate a record that WAS a superuser (getOriginal returns true)
|
||||
$this->targetUser->syncOriginal();
|
||||
$this->targetUser->is_superuser = true;
|
||||
$this->targetUser->syncOriginal();
|
||||
$this->targetUser->is_superuser = false;
|
||||
|
||||
$actor = (new UserFixture)->withPermission('backend.manage_users', true);
|
||||
// getOriginal('is_superuser') is true, so non-superuser can't manage
|
||||
$this->assertFalse($this->targetUser->canBeManagedByUser($actor));
|
||||
}
|
||||
|
||||
public function testCanBeManagedByUserFallsBackToAuthenticatedUser(): void
|
||||
{
|
||||
$actor = (new UserFixture)->withPermission('backend.manage_users', true);
|
||||
$this->actingAs($actor);
|
||||
|
||||
// No explicit user passed — falls back to authenticated user
|
||||
$this->assertTrue($this->targetUser->canBeManagedByUser());
|
||||
}
|
||||
|
||||
//
|
||||
// beforeCreate() / beforeUpdate()
|
||||
//
|
||||
|
||||
public function testUpdateAllowsCliContext(): void
|
||||
{
|
||||
BackendAuth::logout();
|
||||
$this->targetUser->first_name = 'Updated';
|
||||
$this->targetUser->save();
|
||||
$this->assertEquals('Updated', $this->targetUser->first_name);
|
||||
}
|
||||
|
||||
public function testUpdateAllowsSelfNonEscalatingChanges(): void
|
||||
{
|
||||
$this->actingAs($this->targetUser);
|
||||
$this->targetUser->first_name = 'NewName';
|
||||
$this->targetUser->save();
|
||||
$this->assertEquals('NewName', $this->targetUser->first_name);
|
||||
}
|
||||
|
||||
public function testUpdateBlocksSelfEscalation(): void
|
||||
{
|
||||
$this->actingAs($this->targetUser);
|
||||
$this->targetUser->is_superuser = true;
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$this->targetUser->save();
|
||||
}
|
||||
|
||||
public function testUpdateBlocksUnauthorizedUserModifyingOthers(): void
|
||||
{
|
||||
$actor = new UserFixture;
|
||||
$this->actingAs($actor);
|
||||
|
||||
$this->targetUser->first_name = 'Hacked';
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$this->targetUser->save();
|
||||
}
|
||||
|
||||
public function testUpdateAllowsManageUsersActorModifyingOthers(): void
|
||||
{
|
||||
$actor = (new UserFixture)->withPermission('backend.manage_users', true);
|
||||
$this->actingAs($actor);
|
||||
|
||||
$this->targetUser->first_name = 'AdminUpdated';
|
||||
$this->targetUser->save();
|
||||
$this->assertEquals('AdminUpdated', $this->targetUser->first_name);
|
||||
}
|
||||
|
||||
public function testSaveWithNoChangesRequiresNoPermission(): void
|
||||
{
|
||||
$actor = new UserFixture;
|
||||
$this->actingAs($actor);
|
||||
|
||||
// The guard fires on create/update, which only happen when there is
|
||||
// something to write — a no-op save is allowed for anyone (#1464)
|
||||
try {
|
||||
$this->targetUser->save();
|
||||
} catch (AuthorizationException $e) {
|
||||
$this->fail('Should not throw AuthorizationException for a save with no changes');
|
||||
}
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function testCreateBlocksUnauthorizedUser(): void
|
||||
{
|
||||
$actor = new UserFixture;
|
||||
$this->actingAs($actor);
|
||||
|
||||
$user = new User;
|
||||
$user->first_name = 'New';
|
||||
$user->last_name = 'User';
|
||||
$user->login = 'newuser';
|
||||
$user->email = 'new@test.com';
|
||||
$user->password = 'TestPassword1';
|
||||
$user->password_confirmation = 'TestPassword1';
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$user->save();
|
||||
}
|
||||
|
||||
public function testUpdateBlocksNonSuperuserModifyingSuperuser(): void
|
||||
{
|
||||
// Make the target a superuser via direct DB update to bypass model events
|
||||
$this->targetUser->newQuery()->where('id', $this->targetUser->id)->update(['is_superuser' => true]);
|
||||
|
||||
// Re-fetch the model fresh to avoid stale password hash triggering validation
|
||||
$superTarget = User::find($this->targetUser->id);
|
||||
|
||||
$actor = (new UserFixture)->withPermission('backend.manage_users', true);
|
||||
$this->actingAs($actor);
|
||||
|
||||
$superTarget->first_name = 'Hacked';
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$superTarget->save();
|
||||
}
|
||||
|
||||
public function testUpdateSuperuserCanModifyAnyone(): void
|
||||
{
|
||||
$actor = (new UserFixture)->asSuperUser();
|
||||
$this->actingAs($actor);
|
||||
|
||||
$this->targetUser->first_name = 'SuperUpdated';
|
||||
$this->targetUser->save();
|
||||
$this->assertEquals('SuperUpdated', $this->targetUser->first_name);
|
||||
}
|
||||
|
||||
//
|
||||
// Group membership (model.relation.* guard)
|
||||
//
|
||||
|
||||
protected function makeGroup(): UserGroup
|
||||
{
|
||||
return UserGroup::create([
|
||||
'name' => 'Test Group',
|
||||
'code' => 'test-group',
|
||||
]);
|
||||
}
|
||||
|
||||
public function testGroupAttachAllowsManageUsersActor(): void
|
||||
{
|
||||
$group = $this->makeGroup();
|
||||
|
||||
$actor = (new UserFixture)->withPermission('backend.manage_users', true);
|
||||
$this->actingAs($actor);
|
||||
|
||||
$this->targetUser->groups()->add($group);
|
||||
$this->assertEquals(1, $this->targetUser->groups()->count());
|
||||
}
|
||||
|
||||
public function testGroupAttachBlocksUnauthorizedUser(): void
|
||||
{
|
||||
$group = $this->makeGroup();
|
||||
|
||||
$actor = new UserFixture;
|
||||
$this->actingAs($actor);
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$this->targetUser->groups()->add($group);
|
||||
}
|
||||
|
||||
public function testGroupDetachBlocksUnauthorizedUser(): void
|
||||
{
|
||||
$group = $this->makeGroup();
|
||||
$this->targetUser->groups()->add($group);
|
||||
|
||||
$actor = new UserFixture;
|
||||
$this->actingAs($actor);
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$this->targetUser->groups()->remove($group);
|
||||
}
|
||||
|
||||
public function testGroupSyncAssignmentBlocksUnauthorizedUser(): void
|
||||
{
|
||||
$group = $this->makeGroup();
|
||||
|
||||
$actor = new UserFixture;
|
||||
$this->actingAs($actor);
|
||||
|
||||
// Assigning a relation value queues a sync that runs during save without
|
||||
// dirtying any attributes — the relation guard must still catch it
|
||||
$this->targetUser->groups = [$group->id];
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$this->targetUser->save();
|
||||
}
|
||||
|
||||
public function testPluginAddedRelationIsNotGuarded(): void
|
||||
{
|
||||
$group = $this->makeGroup();
|
||||
|
||||
// Simulate a plugin-added relation on the user model; only the relations
|
||||
// core owns (listed in $permissionGuardedRelations) require the permission
|
||||
$this->targetUser->belongsToMany['memberships'] = [
|
||||
UserGroup::class,
|
||||
'table' => 'backend_users_groups',
|
||||
];
|
||||
|
||||
$actor = new UserFixture;
|
||||
$this->actingAs($actor);
|
||||
|
||||
$this->targetUser->memberships()->add($group);
|
||||
$this->assertEquals(1, $this->targetUser->memberships()->count());
|
||||
}
|
||||
|
||||
public function testSelfGroupChangeAllowed(): void
|
||||
{
|
||||
$group = $this->makeGroup();
|
||||
|
||||
$this->actingAs($this->targetUser);
|
||||
|
||||
// Groups carry no permissions out of the box, so changing your own
|
||||
// membership is not an escalation vector and needs no permission
|
||||
$this->targetUser->groups()->add($group);
|
||||
$this->assertEquals(1, $this->targetUser->groups()->count());
|
||||
}
|
||||
|
||||
//
|
||||
// beforeDelete()
|
||||
//
|
||||
|
||||
public function testBeforeDeleteAllowsCliContext(): void
|
||||
{
|
||||
BackendAuth::logout();
|
||||
$this->targetUser->delete();
|
||||
$this->assertTrue($this->targetUser->trashed());
|
||||
}
|
||||
|
||||
public function testBeforeDeleteBlocksUnauthorizedUser(): void
|
||||
{
|
||||
$actor = new UserFixture;
|
||||
$this->actingAs($actor);
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$this->targetUser->delete();
|
||||
}
|
||||
|
||||
public function testBeforeDeleteAllowsManageUsersActor(): void
|
||||
{
|
||||
$actor = (new UserFixture)->withPermission('backend.manage_users', true);
|
||||
$this->actingAs($actor);
|
||||
|
||||
$this->targetUser->delete();
|
||||
$this->assertTrue($this->targetUser->trashed());
|
||||
}
|
||||
|
||||
public function testBeforeDeleteBlocksNonSuperuserDeletingSuperuser(): void
|
||||
{
|
||||
$this->targetUser->newQuery()->where('id', $this->targetUser->id)->update(['is_superuser' => true]);
|
||||
$this->targetUser->refresh();
|
||||
|
||||
$actor = (new UserFixture)->withPermission('backend.manage_users', true);
|
||||
$this->actingAs($actor);
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$this->targetUser->delete();
|
||||
}
|
||||
|
||||
public function testBeforeDeleteSuperuserCanDeleteAnyone(): void
|
||||
{
|
||||
$actor = (new UserFixture)->asSuperUser();
|
||||
$this->actingAs($actor);
|
||||
|
||||
$this->targetUser->delete();
|
||||
$this->assertTrue($this->targetUser->trashed());
|
||||
}
|
||||
|
||||
//
|
||||
// beforeRestore()
|
||||
//
|
||||
|
||||
public function testBeforeRestoreAllowsCliContext(): void
|
||||
{
|
||||
BackendAuth::logout();
|
||||
$this->targetUser->delete();
|
||||
$this->targetUser->restore();
|
||||
$this->assertFalse($this->targetUser->trashed());
|
||||
}
|
||||
|
||||
public function testBeforeRestoreBlocksUnauthorizedUser(): void
|
||||
{
|
||||
BackendAuth::logout();
|
||||
$this->targetUser->delete();
|
||||
|
||||
$actor = new UserFixture;
|
||||
$this->actingAs($actor);
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$this->targetUser->restore();
|
||||
}
|
||||
|
||||
public function testBeforeRestoreAllowsManageUsersActor(): void
|
||||
{
|
||||
BackendAuth::logout();
|
||||
$this->targetUser->delete();
|
||||
|
||||
$actor = (new UserFixture)->withPermission('backend.manage_users', true);
|
||||
$this->actingAs($actor);
|
||||
|
||||
$this->targetUser->restore();
|
||||
$this->assertFalse($this->targetUser->trashed());
|
||||
}
|
||||
|
||||
public function testBeforeRestoreBlocksNonSuperuserRestoringSuperuser(): void
|
||||
{
|
||||
$this->targetUser->newQuery()->where('id', $this->targetUser->id)->update(['is_superuser' => true]);
|
||||
$this->targetUser->refresh();
|
||||
BackendAuth::logout();
|
||||
$this->targetUser->delete();
|
||||
|
||||
$actor = (new UserFixture)->withPermission('backend.manage_users', true);
|
||||
$this->actingAs($actor);
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$this->targetUser->restore();
|
||||
}
|
||||
|
||||
public function testBeforeRestoreSuperuserCanRestoreAnyone(): void
|
||||
{
|
||||
BackendAuth::logout();
|
||||
$this->targetUser->delete();
|
||||
|
||||
$actor = (new UserFixture)->asSuperUser();
|
||||
$this->actingAs($actor);
|
||||
|
||||
$this->targetUser->restore();
|
||||
$this->assertFalse($this->targetUser->trashed());
|
||||
}
|
||||
|
||||
//
|
||||
// unsuspend()
|
||||
//
|
||||
|
||||
public function testUnsuspendBlocksUnauthorizedUser(): void
|
||||
{
|
||||
$actor = new UserFixture;
|
||||
$this->actingAs($actor);
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$this->targetUser->unsuspend();
|
||||
}
|
||||
|
||||
public function testUnsuspendAllowsManageUsersActor(): void
|
||||
{
|
||||
$actor = (new UserFixture)->withPermission('backend.manage_users', true);
|
||||
$this->actingAs($actor);
|
||||
|
||||
// Should not throw — the throttle record may not exist but that's fine
|
||||
// for testing the authorization gate
|
||||
try {
|
||||
$this->targetUser->unsuspend();
|
||||
} catch (AuthorizationException $e) {
|
||||
$this->fail('Should not throw AuthorizationException for manage_users actor');
|
||||
}
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function testUnsuspendAllowsCliContext(): void
|
||||
{
|
||||
BackendAuth::logout();
|
||||
|
||||
try {
|
||||
$this->targetUser->unsuspend();
|
||||
} catch (AuthorizationException $e) {
|
||||
$this->fail('Should not throw AuthorizationException in CLI context');
|
||||
}
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
//
|
||||
// getResetPasswordCode()
|
||||
//
|
||||
|
||||
public function testGetResetPasswordCodeAllowsSelfService(): void
|
||||
{
|
||||
$this->actingAs($this->targetUser);
|
||||
$code = $this->targetUser->getResetPasswordCode();
|
||||
$this->assertNotEmpty($code);
|
||||
}
|
||||
|
||||
public function testGetResetPasswordCodeBlocksUnauthorizedUserForOthers(): void
|
||||
{
|
||||
$actor = new UserFixture;
|
||||
$this->actingAs($actor);
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$this->targetUser->getResetPasswordCode();
|
||||
}
|
||||
|
||||
public function testGetResetPasswordCodeAllowsManageUsersActorForOthers(): void
|
||||
{
|
||||
$actor = (new UserFixture)->withPermission('backend.manage_users', true);
|
||||
$this->actingAs($actor);
|
||||
|
||||
$code = $this->targetUser->getResetPasswordCode();
|
||||
$this->assertNotEmpty($code);
|
||||
}
|
||||
|
||||
public function testGetResetPasswordCodeAllowsCliContext(): void
|
||||
{
|
||||
BackendAuth::logout();
|
||||
$code = $this->targetUser->getResetPasswordCode();
|
||||
$this->assertNotEmpty($code);
|
||||
}
|
||||
}
|
||||
224
modules/backend/tests/models/UserTest.php
Normal file
224
modules/backend/tests/models/UserTest.php
Normal file
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
namespace Backend\Tests\Models;
|
||||
|
||||
use Backend\Models\User;
|
||||
use Backend\Models\UserRole;
|
||||
use System\Tests\Bootstrap\PluginTestCase;
|
||||
use Winter\Storm\Auth\AuthorizationException;
|
||||
use Winter\Storm\Database\Model as Eloquent;
|
||||
|
||||
class UserTest extends PluginTestCase
|
||||
{
|
||||
protected User $superuser;
|
||||
protected User $admin;
|
||||
protected User $lowPriv;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Eloquent::unguarded(function () {
|
||||
$developerRole = UserRole::where('code', UserRole::CODE_DEVELOPER)->first();
|
||||
$publisherRole = UserRole::where('code', UserRole::CODE_PUBLISHER)->first();
|
||||
|
||||
$this->superuser = User::create([
|
||||
'email' => 'superuser@test.com',
|
||||
'login' => 'superuser',
|
||||
'password' => 'Testing123!',
|
||||
'password_confirmation' => 'Testing123!',
|
||||
'first_name' => 'Super',
|
||||
'last_name' => 'User',
|
||||
'is_superuser' => true,
|
||||
'is_activated' => true,
|
||||
'role_id' => $developerRole->id,
|
||||
'permissions' => [],
|
||||
]);
|
||||
|
||||
$this->admin = User::create([
|
||||
'email' => 'admin@test.com',
|
||||
'login' => 'admin_user',
|
||||
'password' => 'Testing123!',
|
||||
'password_confirmation' => 'Testing123!',
|
||||
'first_name' => 'Admin',
|
||||
'last_name' => 'User',
|
||||
'is_superuser' => false,
|
||||
'is_activated' => true,
|
||||
'role_id' => $publisherRole->id,
|
||||
'permissions' => ['backend.manage_users' => 1],
|
||||
]);
|
||||
|
||||
$this->lowPriv = User::create([
|
||||
'email' => 'lowpriv@test.com',
|
||||
'login' => 'lowpriv',
|
||||
'password' => 'Testing123!',
|
||||
'password_confirmation' => 'Testing123!',
|
||||
'first_name' => 'Low',
|
||||
'last_name' => 'Priv',
|
||||
'is_superuser' => false,
|
||||
'is_activated' => true,
|
||||
'role_id' => null,
|
||||
'permissions' => [],
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Denied operations ----
|
||||
|
||||
public function testSelfEditRoleThrows()
|
||||
{
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
$this->admin->role_id = $this->admin->role_id + 1;
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$this->admin->save();
|
||||
}
|
||||
|
||||
public function testSelfEditSuperuserThrows()
|
||||
{
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
$this->admin->is_superuser = true;
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$this->admin->save();
|
||||
}
|
||||
|
||||
public function testSelfEditPermissionsThrows()
|
||||
{
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
$this->admin->permissions = ['backend.manage_users' => 1, 'backend.access_dashboard' => 1];
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$this->admin->save();
|
||||
}
|
||||
|
||||
public function testNonSuperuserGrantSuperuserThrows()
|
||||
{
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
$this->lowPriv->is_superuser = true;
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$this->lowPriv->save();
|
||||
}
|
||||
|
||||
public function testNonSuperuserRevokeSuperuserThrows()
|
||||
{
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
$this->superuser->is_superuser = false;
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$this->superuser->save();
|
||||
}
|
||||
|
||||
public function testNonSuperuserEditingSuperuserThrows()
|
||||
{
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
$this->superuser->first_name = 'Changed';
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$this->superuser->save();
|
||||
}
|
||||
|
||||
public function testNoManageUsersEditingOtherThrows()
|
||||
{
|
||||
$this->actingAs($this->lowPriv);
|
||||
|
||||
$this->admin->first_name = 'Changed';
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$this->admin->save();
|
||||
}
|
||||
|
||||
public function testNoManageUsersCreatingUserThrows()
|
||||
{
|
||||
$this->actingAs($this->lowPriv);
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
Eloquent::unguarded(function () {
|
||||
User::create([
|
||||
'email' => 'newuser@test.com',
|
||||
'login' => 'newuser',
|
||||
'password' => 'Testing123!',
|
||||
'password_confirmation' => 'Testing123!',
|
||||
'first_name' => 'New',
|
||||
'last_name' => 'User',
|
||||
'is_superuser' => false,
|
||||
'is_activated' => true,
|
||||
'permissions' => [],
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Allowed operations ----
|
||||
|
||||
public function testSelfEditNonProtectedFieldsAllowed()
|
||||
{
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
$this->admin->first_name = 'NewFirst';
|
||||
$this->admin->last_name = 'NewLast';
|
||||
$this->admin->email = 'newemail@test.com';
|
||||
$this->admin->save();
|
||||
|
||||
$this->admin->refresh();
|
||||
$this->assertEquals('NewFirst', $this->admin->first_name);
|
||||
$this->assertEquals('NewLast', $this->admin->last_name);
|
||||
$this->assertEquals('newemail@test.com', $this->admin->email);
|
||||
}
|
||||
|
||||
public function testAdminCanChangeOtherUserRole()
|
||||
{
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
$publisherRole = UserRole::where('code', UserRole::CODE_PUBLISHER)->first();
|
||||
$this->lowPriv->role_id = $publisherRole->id;
|
||||
$this->lowPriv->save();
|
||||
|
||||
$this->lowPriv->refresh();
|
||||
$this->assertEquals($publisherRole->id, $this->lowPriv->role_id);
|
||||
}
|
||||
|
||||
public function testSuperuserCanGrantSuperuser()
|
||||
{
|
||||
$this->actingAs($this->superuser);
|
||||
|
||||
$this->lowPriv->is_superuser = true;
|
||||
$this->lowPriv->save();
|
||||
|
||||
$this->lowPriv->refresh();
|
||||
$this->assertTrue((bool) $this->lowPriv->is_superuser);
|
||||
}
|
||||
|
||||
public function testAdminCanChangeOtherUserPermissions()
|
||||
{
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
$this->lowPriv->permissions = ['backend.access_dashboard' => 1];
|
||||
$this->lowPriv->save();
|
||||
|
||||
$this->lowPriv->refresh();
|
||||
$this->assertEquals(['backend.access_dashboard' => 1], $this->lowPriv->permissions);
|
||||
}
|
||||
|
||||
public function testNoAuthUserCanModifyAnyField()
|
||||
{
|
||||
// No actingAs — simulates CLI/artisan/queue context
|
||||
$developerRole = UserRole::where('code', UserRole::CODE_DEVELOPER)->first();
|
||||
|
||||
$this->lowPriv->role_id = $developerRole->id;
|
||||
$this->lowPriv->is_superuser = true;
|
||||
$this->lowPriv->permissions = ['backend.manage_users' => 1];
|
||||
$this->lowPriv->save();
|
||||
|
||||
$this->lowPriv->refresh();
|
||||
$this->assertEquals($developerRole->id, $this->lowPriv->role_id);
|
||||
$this->assertTrue((bool) $this->lowPriv->is_superuser);
|
||||
$this->assertEquals(['backend.manage_users' => 1], $this->lowPriv->permissions);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user