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