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