Files
vivespos-landing/modules/system/tests/classes/CombineAssetsTest.php
avives 1f72193a64
Some checks are pending
Module sub-split / Sub-split (push) Waiting to run
feat: VivesPOS landing on Winter CMS 1.2 — theme + plugin + Dockerfile
- 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
2026-08-21 19:29:00 -06:00

298 lines
11 KiB
PHP

<?php
namespace System\Tests\Classes;
use System\Tests\Bootstrap\TestCase;
use Cms\Classes\Theme;
use System\Classes\CombineAssets;
class CombineAssetsTest extends TestCase
{
public function setUp() : void
{
parent::setUp();
CombineAssets::resetCache();
}
//
// Tests
//
public function testCombiner()
{
$combiner = CombineAssets::instance();
/*
* Supported file extensions should exist
*/
$jsExt = $cssExt = self::getProtectedProperty($combiner, 'jsExtensions');
$this->assertIsArray($jsExt);
$cssExt = self::getProtectedProperty($combiner, 'cssExtensions');
$this->assertIsArray($cssExt);
/*
* Check service methods
*/
$this->assertTrue(method_exists($combiner, 'combine'));
$this->assertTrue(method_exists($combiner, 'resetCache'));
}
public function testCombine()
{
$combiner = CombineAssets::instance();
$url = $combiner->combine(
[
'assets/css/style1.css',
'assets/css/style2.css'
],
base_path() . '/modules/system/tests/fixtures/themes/test'
);
$this->assertNotNull($url);
$this->assertRegExp('/\w+[-]\d+/i', $url); // Must contain hash-number
$url = $combiner->combine(
[
'assets/js/script1.js',
'assets/js/script2.js'
],
base_path() . '/modules/system/tests/fixtures/themes/test'
);
$this->assertNotNull($url);
$this->assertRegExp('/\w+[-]\d+/i', $url); // Must contain hash-number
}
public function testPutCache()
{
$sampleId = md5('testhash');
$sampleStore = ['version' => 12345678];
$samplePath = '/tests/fixtures/Cms/themes/test';
$combiner = CombineAssets::instance();
$value = self::callProtectedMethod($combiner, 'putCache', [$sampleId, $sampleStore]);
$this->assertTrue($value);
}
public function testGetTargetPath()
{
$combiner = CombineAssets::instance();
$value = self::callProtectedMethod($combiner, 'getTargetPath', ['/combine']);
$this->assertEquals('combine/', $value);
$value = self::callProtectedMethod($combiner, 'getTargetPath', ['/index.php/combine']);
$this->assertEquals('index-php/combine/', $value);
}
public function testMakeCacheId()
{
$sampleResources = ['assets/css/style1.css', 'assets/css/style2.css'];
$samplePath = base_path() . '/modules/system/tests/fixtures/cms/themes/test';
$combiner = CombineAssets::instance();
self::setProtectedProperty($combiner, 'localPath', $samplePath);
$value = self::callProtectedMethod($combiner, 'getCacheKey', [$sampleResources]);
$this->assertEquals(md5($samplePath.implode('|', $sampleResources)), $value);
}
public function testResetCache()
{
$combiner = CombineAssets::instance();
$this->assertNull($combiner->resetCache());
}
/**
* Regression for GHSA-58fp-mcx6-7qf9. A writable theme `.less` file containing
* `@import (inline) "<absolute-path>"` must not disclose server files.
*/
public function testLessCompilerBlocksAbsolutePathImport()
{
[$themeDir, $secretPath] = $this->setupLessLeakFixture(
'@import (inline) "%SECRET%"; .x { color: red; }'
);
try {
$css = $this->compileLessTo($themeDir, 'assets/less/poc.less');
$this->assertStringNotContainsString('APP_KEY', $css);
$this->assertStringNotContainsString('combine-leak-canary', $css);
} finally {
$this->teardownLessLeakFixture($themeDir, $secretPath);
}
}
/**
* Regression for the relative-traversal path of GHSA-58fp-mcx6-7qf9. less.php's
* auto-added `currentDirectory` import_dir entry resolves `..` traversal
* natively; without the key-collision override in LessImportResolver, a theme
* `.less` could still escape via `@import (inline) "../../../etc/passwd"`.
*/
public function testLessCompilerBlocksRelativeTraversalImport()
{
// From themeDir/assets/less/poc.less, traverse up enough to escape the
// theme tree, the themes root, and out to the secret file the fixture
// wrote at sys_get_temp_dir().
[$themeDir, $secretPath] = $this->setupLessLeakFixture(
'@import (inline) "' . str_repeat('../', 20) . ltrim($this->lastSecretPath, '/') . '"; .x { color: red; }'
);
try {
$css = $this->compileLessTo($themeDir, 'assets/less/poc.less');
$this->assertStringNotContainsString('APP_KEY', $css);
$this->assertStringNotContainsString('combine-leak-canary', $css);
} finally {
$this->teardownLessLeakFixture($themeDir, $secretPath);
}
}
/**
* Legitimate same-tree `@import "partial.less"` must still resolve through
* the gate, otherwise we've broken every theme that uses partials.
*/
public function testLessCompilerAllowsLegitimatePartial()
{
$themeDir = $this->makeTempThemeDir();
$mainPath = $themeDir . '/assets/less/main.less';
$partialPath = $themeDir . '/assets/less/partial.less';
file_put_contents($partialPath, '.partial-marker { color: orange; }');
file_put_contents($mainPath, '@import "partial.less"; .main-marker { color: blue; }');
try {
$css = $this->compileLessTo($themeDir, 'assets/less/main.less');
$this->assertStringContainsString('partial-marker', $css);
$this->assertStringContainsString('main-marker', $css);
} finally {
\File::deleteDirectory($themeDir);
}
}
/**
* Regression for GHSA-2223-f22x-24cq. A writable theme `.js` asset containing
* `=include ../../../.env` must not disclose server files through the combiner,
* whose output is served unauthenticated via the `combine/{file}` route.
*/
public function testJavascriptImporterBlocksTraversalImport()
{
$themeDir = $this->makeTempThemeDir();
// A real `.js` secret outside the theme subtree (but under base_path so
// Assetic's FileAsset root check passes). It escapes via `..` traversal but
// lands outside every allowed import root, so it must not be inlined.
$secretPath = dirname($themeDir) . '/js-secret-' . bin2hex(random_bytes(4)) . '.js';
file_put_contents($secretPath, 'var LEAK = "combine-leak-canary";');
file_put_contents(
$themeDir . '/assets/poc.js',
"/*\n=include ../../" . basename($secretPath) . "\n*/\n"
);
try {
$js = $this->compileJsTo($themeDir, 'assets/poc.js');
$this->assertStringNotContainsString('combine-leak-canary', $js);
} finally {
@unlink($secretPath);
\File::deleteDirectory($themeDir);
}
}
/**
* The `.js`-only extension gate must block disclosure of non-JS files (e.g.
* `.env`) before any path resolution, even inside an otherwise reachable tree.
*/
public function testJavascriptImporterBlocksDisallowedExtension()
{
$themeDir = $this->makeTempThemeDir();
$secretPath = dirname($themeDir) . '/js-secret-' . bin2hex(random_bytes(4)) . '.env';
file_put_contents($secretPath, "APP_KEY=combine-leak-canary\n");
file_put_contents(
$themeDir . '/assets/poc.js',
"/*\n=include ../../" . basename($secretPath) . "\n*/\n"
);
try {
$js = $this->compileJsTo($themeDir, 'assets/poc.js');
$this->assertStringNotContainsString('combine-leak-canary', $js);
} finally {
@unlink($secretPath);
\File::deleteDirectory($themeDir);
}
}
/**
* Legitimate same-tree `=include partial.js` must still resolve, otherwise the
* hardening would break every asset that composes its own bundle.
*/
public function testJavascriptImporterAllowsSameTreeInclude()
{
$themeDir = $this->makeTempThemeDir();
file_put_contents($themeDir . '/assets/partial.js', 'var PARTIAL = "partial-marker";');
file_put_contents($themeDir . '/assets/main.js', "/*\n=include partial.js\n*/\nvar MAIN = 1;");
try {
$js = $this->compileJsTo($themeDir, 'assets/main.js');
$this->assertStringContainsString('partial-marker', $js);
} finally {
\File::deleteDirectory($themeDir);
}
}
protected function compileJsTo(string $themeDir, string $relativeAsset): string
{
$dest = sys_get_temp_dir() . '/winter-combine-out-' . bin2hex(random_bytes(4)) . '.js';
try {
CombineAssets::instance()->combineToFile([$relativeAsset], $dest, $themeDir);
return file_get_contents($dest) ?: '';
} finally {
@unlink($dest);
}
}
/** @var string */
protected $lastSecretPath = '';
/**
* @return array{0:string,1:string} [theme dir, secret path]
*/
protected function setupLessLeakFixture(string $pocTemplate): array
{
$themeDir = $this->makeTempThemeDir();
$secretPath = tempnam(sys_get_temp_dir(), 'combine-leak-canary-');
file_put_contents($secretPath, "APP_KEY=do-not-leak-via-combiner\n");
$this->lastSecretPath = $secretPath;
$poc = str_replace('%SECRET%', $secretPath, $pocTemplate);
file_put_contents($themeDir . '/assets/less/poc.less', $poc);
return [$themeDir, $secretPath];
}
protected function teardownLessLeakFixture(string $themeDir, string $secretPath): void
{
@unlink($secretPath);
\File::deleteDirectory($themeDir);
}
protected function makeTempThemeDir(): string
{
// Must live under base_path() because Assetic's FileAsset enforces that
// the source be within the configured root, which CombineAssets sets to
// public_path() (equal to base_path() in this install). Using sys_get_temp_dir()
// would trigger "source is not in the root directory" errors.
$themeDir = base_path('storage/framework/cache/security-tests/theme-' . bin2hex(random_bytes(4)));
mkdir($themeDir . '/assets/less', 0777, true);
return $themeDir;
}
protected function compileLessTo(string $themeDir, string $relativeAsset): string
{
$dest = sys_get_temp_dir() . '/winter-combine-out-' . bin2hex(random_bytes(4)) . '.css';
try {
CombineAssets::instance()->combineToFile([$relativeAsset], $dest, $themeDir);
return file_get_contents($dest) ?: '';
} finally {
@unlink($dest);
}
}
}