isDir() && !$item->isLink()) ? rmdir($item->getPathname()) : unlink($item->getPathname()); } rmdir($path); } /** Raise an immediately useful test failure when a behavioural expectation is false. */ function assertTrue(bool $condition, string $message): void { if (!$condition) { throw new RuntimeException($message); } } /** Assert that an operation is rejected with an application-level runtime exception. */ function assertRejected(callable $operation, string $message): void { try { $operation(); } catch (RuntimeException $exception) { return; } throw new RuntimeException($message); } // Build isolated public and metadata roots for this test run. $root = sys_get_temp_dir() . '/neocms-test-' . bin2hex(random_bytes(5)); $data = $root . '-data'; $outside = $root . '-outside'; mkdir($root, 0755, true); mkdir($outside, 0755, true); // The primary fixture uses a non-default editable class plus shared and menu regions. $page = 'Test' . '
Original
' . '' . '' . ''; file_put_contents($root . '/index.html', $page); file_put_contents($root . '/ignored.html', '
Wrong configured class
'); // Seed an authenticated administrator session without submitting real credentials. $_SERVER['DOCUMENT_ROOT'] = $root; session_id('neocms-test-' . bin2hex(random_bytes(5))); session_start(); $_SESSION = [ 'loggedIn' => true, 'loggedInUser' => 'tester', 'role' => 'administrator', 'csrfToken' => 'test-token', 'authenticatedAt' => time(), 'lastActivityAt' => time(), 'lastRegeneratedAt' => time(), ]; // Point the controller at temporary storage and the deliberately overridden editable class. $config = [ 'authentication' => ['tester' => password_hash('unused', PASSWORD_DEFAULT)], 'roles' => ['tester' => 'administrator'], 'audit' => false, 'editableClass' => 'cms-content', 'dataDirectory' => $data, 'security' => [ 'maxContentBytes' => 4096, 'maxRequestBytes' => 8192, 'maxRevisionsPerPage' => 2, 'maxRevisionsTotal' => 4, 'maxRevisionBytes' => 32768, 'maxSchedules' => 5, 'maxScheduledBytes' => 32768, 'maxDraftBytes' => 32768, ], ]; /** * Simulate one controller request and return its decoded successful JSON response. * Errors are promoted to exceptions so the test stops at the first broken contract. */ $request = function (string $action, array $parameters = [], string $method = 'GET') use ($config): array { $_SERVER['REQUEST_METHOD'] = $method; $_GET = $method === 'GET' ? array_merge(['action' => $action], $parameters) : []; $_POST = $method === 'POST' ? array_merge(['action' => $action, 'csrf_token' => 'test-token'], $parameters) : []; $_REQUEST = array_merge($_GET, $_POST); http_response_code(200); ob_start(); (new NeoCMS\CMSController($config))->handleRequest(); $body = (string) ob_get_clean(); $response = json_decode($body, true); if (!is_array($response)) { throw new RuntimeException('Invalid JSON response: ' . $body); } if (isset($response['error'])) { throw new RuntimeException($action . ' failed: ' . $response['error']); } return $response; }; try { // The administration preview must retain DOM access while refusing all page script execution. $adminShell = (string) file_get_contents(__DIR__ . '/../cms/index.php'); assertTrue(str_contains($adminShell, 'sandbox="allow-same-origin"'), 'Administration preview is not sandboxed'); assertTrue(!str_contains($adminShell, 'allow-scripts'), 'Administration preview permits page scripts'); assertTrue(!str_contains($adminShell, 'bootstrap.min'), 'End-of-life Bootstrap assets remain enabled'); assertTrue(str_contains($adminShell, 'jquery-4.0.0.min.js') && str_contains($adminShell, 'integrity="sha384-'), 'Pinned jQuery assets are missing SRI'); $tinyMce = (string) file_get_contents(__DIR__ . '/../cms/tinymce/tinymce.min.js'); assertTrue(str_contains($tinyMce, 'majorVersion:"8",minorVersion:"6.0"'), 'TinyMCE is not the audited 8.6.0 release'); $defaultConfig = (string) file_get_contents(__DIR__ . '/../cms/config.php'); assertTrue(!str_contains($defaultConfig, 'change-this-password'), 'Known default password remains in configuration'); // Authentication must reject plaintext credentials and default missing roles to editor. $authentication = new NeoCMS\Authentication(['legacy' => 'plaintext'], ['legacy' => 'administrator']); assertTrue(!$authentication->login('legacy', 'plaintext'), 'Plaintext credentials were accepted'); $limitedHash = password_hash('secret', PASSWORD_DEFAULT); $authentication = new NeoCMS\Authentication(['limited' => $limitedHash], ['limited' => 'administrator']); assertTrue($authentication->login('limited', 'secret'), 'A valid password hash was rejected'); $demoted = new NeoCMS\Authentication(['limited' => $limitedHash], ['limited' => 'editor']); assertTrue($demoted->getRole() === 'editor', 'Role demotion did not affect the active session'); $rotated = new NeoCMS\Authentication(['limited' => password_hash('new-secret', PASSWORD_DEFAULT)], ['limited' => 'editor']); assertTrue(!$rotated->isLoggedIn(), 'Password rotation did not revoke the active session'); $authentication = new NeoCMS\Authentication(['limited' => $limitedHash]); assertTrue($authentication->login('limited', 'secret'), 'A valid hash could not establish a second session'); assertTrue($authentication->getRole() === 'editor', 'Missing role assignment did not fail to editor'); // Idle sessions must lose authenticated state before another privileged request can use them. $_SESSION = [ 'loggedIn' => true, 'loggedInUser' => 'tester', 'role' => 'administrator', 'csrfToken' => 'test-token', 'authenticatedAt' => time() - 1000, 'lastActivityAt' => time() - 1000, 'lastRegeneratedAt' => time() - 1000, ]; $expired = new NeoCMS\Authentication([], [], ['idleTimeout' => 300, 'absoluteTimeout' => 600]); assertTrue(!$expired->isLoggedIn(), 'Expired session remained authenticated'); // Restore the administrator session used by controller integration tests. $_SESSION = [ 'loggedIn' => true, 'loggedInUser' => 'tester', 'role' => 'administrator', 'csrfToken' => 'test-token', 'authenticatedAt' => time(), 'lastActivityAt' => time(), 'lastRegeneratedAt' => time(), ]; // Repeated denied logins must trigger a bounded lockout and be clearable after success. $limiter = new NeoCMS\LoginRateLimiter($data . '/rate-limit', [ 'loginMaxAttempts' => 2, 'loginMaxAddressAttempts' => 10, 'loginLockoutSeconds' => 60, ]); $limiter->recordFailure('192.0.2.10', 'tester'); $limiter->recordFailure('192.0.2.10', 'tester'); assertTrue($limiter->retryAfter('192.0.2.10', 'tester') > 0, 'Login throttling did not activate'); $limiter->clear('192.0.2.10', 'tester'); assertTrue($limiter->retryAfter('192.0.2.10', 'tester') === 0, 'Login throttling did not clear'); // Page discovery must honour editableClass and ignore the default marker when overridden. $pages = $request('getPages'); assertTrue(count($pages) === 1 && $pages[0]['url'] === '/index.html', 'Configured editable class was not respected'); // Recursive discovery and new-page creation must not follow symlinks outside the document root. file_put_contents($outside . '/secret.html', '
Outside
'); if (function_exists('symlink') && @symlink($outside . '/secret.html', $root . '/leak.html')) { assertTrue(count($request('getPages')) === 1, 'Page discovery followed an external file symlink'); } // Drafts must remain private and leave the public HTML untouched. $draftContent = str_replace('Original', 'Draft', $page); $request('saveDraft', ['uri' => '/index.html', 'content' => $draftContent], 'POST'); $draft = $request('getDraft', ['uri' => '/index.html']); assertTrue($draft['exists'] && str_contains($draft['content'], 'Draft'), 'Draft was not saved'); assertTrue(str_contains((string) file_get_contents($root . '/index.html'), 'Original'), 'Draft changed the public page'); // Publication must replace public HTML, clear its draft, and retain a revision. $published = str_replace('Original', 'Published', $page); $request('save', ['uri' => '/index.html', 'content' => $published], 'POST'); assertTrue(str_contains((string) file_get_contents($root . '/index.html'), 'Published'), 'Page was not published'); assertTrue(!$request('getDraft', ['uri' => '/index.html'])['exists'], 'Published draft was not cleared'); $revisions = $request('revisions', ['uri' => '/index.html']); assertTrue(count($revisions) >= 1, 'Publishing did not create a revision'); assertRejected( fn() => $request('saveDraft', ['uri' => '/index.html', 'content' => str_repeat('x', 5000)], 'POST'), 'Oversized page content was accepted' ); // Administrator page operations must support duplicate, rename, delete, and restoration. $request('page', ['operation' => 'duplicate', 'uri' => '/index.html', 'target' => '/copy.html'], 'POST'); assertTrue(!isset((new NeoCMS\FileStore($data))->read('pagelocks')['/copy.html']), 'Duplicating a page copied its lock'); $request('lockPage', ['uri' => '/copy.html', 'value' => '1'], 'POST'); $request('page', ['operation' => 'rename', 'uri' => '/copy.html', 'target' => '/renamed.html'], 'POST'); assertTrue(is_file($root . '/renamed.html'), 'Duplicate or rename failed'); $locksAfterRename = (new NeoCMS\FileStore($data))->read('pagelocks'); assertTrue(!isset($locksAfterRename['/copy.html']) && isset($locksAfterRename['/renamed.html']), 'Renaming a page did not move its lock entry'); $request('page', ['operation' => 'delete', 'uri' => '/renamed.html'], 'POST'); assertTrue(!is_file($root . '/renamed.html'), 'Page delete failed'); assertTrue(!isset((new NeoCMS\FileStore($data))->read('pagelocks')['/renamed.html']), 'Deleting a page left a stale lock entry'); assertTrue(in_array('/renamed.html', array_column($request('dashboard')['deleted'], 'uri'), true), 'Deleted page was not listed on the dashboard'); $deletedRevisions = $request('revisions', ['uri' => '/renamed.html']); $request('restoreRevision', ['id' => $deletedRevisions[0]['id']], 'POST'); assertTrue(is_file($root . '/renamed.html'), 'Deleted page revision was not restored'); assertTrue(!in_array('/renamed.html', array_column($request('dashboard')['deleted'], 'uri'), true), 'Recovered page is still listed as deleted'); // Shared content must propagate ordinary forgiving HTML fragments to marked pages. $request('saveShared', ['key' => 'footer', 'content' => 'Shared
footer
'], 'POST'); assertTrue(str_contains((string) file_get_contents($root . '/index.html'), 'Shared
footer
'), 'Shared HTML was not propagated'); // Non-ASCII text must survive propagation unchanged (libxml assumes Latin-1 without a hint). $request('saveShared', ['key' => 'footer', 'content' => 'Café – 日本語'], 'POST'); // libxml may write entities (é); they must decode back to the original text, not to Latin-1 mojibake. assertTrue(str_contains(html_entity_decode((string) file_get_contents($root . '/index.html'), ENT_QUOTES, 'UTF-8'), 'Café – 日本語'), 'Non-ASCII shared content was mangled'); // Nested menus must render and replace existing generated navigation regions. $items = json_encode([ ['label' => 'Home', 'url' => '/', 'parent' => ''], ['label' => 'Team', 'url' => '/team.html', 'parent' => 'Home'], ]); (new NeoCMS\FileStore($data))->write('menus', ['main' => ['items' => [], 'updated' => date(DATE_ATOM)]]); assertRejected(fn() => $request('saveMenu', ['name' => 'brand-new', 'items' => $items], 'POST'), 'The CMS created a menu that does not exist'); $request('saveMenu', ['name' => 'main', 'title' => 'Main navigation', 'items' => $items], 'POST'); assertTrue(($request('menus')['main']['title'] ?? '') === 'Main navigation' && !isset($request('menus')['brand-new']), 'Menu was not renamed in place'); $menuPage = (string) file_get_contents($root . '/index.html'); assertTrue(str_contains($menuPage, 'href="/team.html"') && str_contains($menuPage, 'data-neo-menu="main"'), 'Menu was not propagated'); // Future schedules must appear on the dashboard and be cancellable. $schedule = $request('schedule', [ 'uri' => '/index.html', 'content' => str_replace('Published', 'Scheduled', $published), 'publish_at' => date(DATE_ATOM, time() + 3600), ], 'POST'); $dashboard = $request('dashboard'); assertTrue(isset($dashboard['schedules'][$schedule['id']]), 'Schedule was not visible on the dashboard'); $request('cancelSchedule', ['id' => $schedule['id']], 'POST'); $afterCancel = $request('dashboard'); assertTrue(!isset($afterCancel['schedules'][$schedule['id']]), 'Schedule was not cancelled'); assertTrue(in_array('Cancelled scheduled publication', array_column($afterCancel['activity'], 'action'), true), 'Cancelling a schedule was not recorded in the activity log'); $request('cancelSchedule', ['id' => $schedule['id']], 'POST'); // cancelling twice must not log a second event assertTrue(count(array_filter($request('dashboard')['activity'], fn($e) => $e['action'] === 'Cancelled scheduled publication')) === 1, 'A schedule that no longer exists was logged as cancelled'); // Alt-text edits and uploads are recorded too. $request('updateMedia', ['name' => str_repeat('a', 32) . '.png', 'alt' => 'A test image'], 'POST'); assertTrue(in_array('Updated image alt text', array_column($request('dashboard')['activity'], 'action'), true), 'Alt text change was not recorded'); $uploadActivityDir = sys_get_temp_dir() . '/neocms-activity-' . bin2hex(random_bytes(4)); (new NeoCMS\Activity(new NeoCMS\FileStore($uploadActivityDir), new NeoCMS\Logger(false, [])))->record('tester', 'Uploaded image', 'x.png (5 KB)'); $recorded = (new NeoCMS\FileStore($uploadActivityDir))->read('activity'); assertTrue(count($recorded) === 1 && $recorded[0]['action'] === 'Uploaded image' && $recorded[0]['user'] === 'tester', 'Upload activity entry was not written'); removeTree($uploadActivityDir); assertRejected( fn() => $request('schedule', ['uri' => '/missing.html', 'content' => $published, 'publish_at' => date(DATE_ATOM, time() + 3600)], 'POST'), 'A schedule targeting a missing page was accepted' ); assertRejected( fn() => $request('deleteMedia', ['name' => 'index.php'], 'POST'), 'Media deletion accepted a non-managed filename' ); // Force a queued job into the past to verify the unattended publishing worker path. $due = $request('schedule', [ 'uri' => '/index.html', 'content' => str_replace('Published', 'Scheduled', $published), 'publish_at' => date(DATE_ATOM, time() + 3600), ], 'POST'); $schedulePath = $data . '/schedules.json'; $scheduleData = json_decode((string) file_get_contents($schedulePath), true); $scheduleData[$due['id']]['publish_at'] = date(DATE_ATOM, time() - 60); file_put_contents($schedulePath, json_encode($scheduleData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); $publishedCount = (new NeoCMS\CMSController($config))->publishScheduled(); assertTrue($publishedCount === 1, 'Due schedule was not processed'); assertTrue(str_contains((string) file_get_contents($root . '/index.html'), 'Scheduled'), 'Scheduled content was not published'); // A page removed before its due time must mark only that job failed, not block the whole API. $failed = $request('schedule', [ 'uri' => '/renamed.html', 'content' => $published, 'publish_at' => date(DATE_ATOM, time() + 3600), ], 'POST'); unlink($root . '/renamed.html'); $scheduleData = json_decode((string) file_get_contents($schedulePath), true); $scheduleData[$failed['id']]['publish_at'] = date(DATE_ATOM, time() - 60); file_put_contents($schedulePath, json_encode($scheduleData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); assertTrue((new NeoCMS\CMSController($config))->publishScheduled() === 0, 'Invalid scheduled target was reported as published'); $dashboardAfterFailure = $request('dashboard'); assertTrue(($dashboardAfterFailure['schedules'][$failed['id']]['status'] ?? '') === 'failed', 'Failed schedule was not isolated'); assertTrue(in_array('Scheduled publication failed', array_column($dashboardAfterFailure['activity'], 'action'), true), 'A failed scheduled publication was not shown in the activity feed'); // Revision retention limits must cap both per-page and total private storage metadata. $revisionIndex = json_decode((string) file_get_contents($data . '/revisions.json'), true); assertTrue(is_array($revisionIndex) && count($revisionIndex) <= 4, 'Global revision retention limit was exceeded'); $indexRevisionCount = count(array_filter($revisionIndex, static fn(array $revision): bool => ($revision['uri'] ?? '') === '/index.html')); assertTrue($indexRevisionCount <= 2, 'Per-page revision retention limit was exceeded'); // Private CMS state should not be readable by unrelated operating-system users. // ---- Site scan: byte-exact tagging of content containers, images, and SEO tags ---- $all = ['content' => true, 'images' => true, 'seo' => true]; $scanPage = "\r\n\r\n \r\n\r\n\r\n" . "
\r\n" . "\r\n" . "
\r\n

Café – Über uns

\r\n

Wir sind ein kleines Team mit grossen Ideen.

\r\n\"\"\r\n
\r\n" . "\r\n" . "\r\n\r\n"; $tagged = NeoCMS\SiteAnalyser::apply($scanPage, 'cms-content', $all); assertTrue($tagged !== null, 'Site scan found nothing to tag'); $out = $tagged['html']; assertTrue(str_contains($out, '
'), 'Existing class attribute was not merged'); assertTrue(!str_contains($out, "
'), 'Only the image outside the region should be tagged'); assertTrue(str_contains($out, 'Café – Über uns') && str_contains($out, 'name="description"'), 'Missing SEO tags were not inserted'); assertTrue(substr_count($out, "\n") === substr_count($out, "\r\n"), 'Line endings became mixed'); assertTrue(NeoCMS\SiteAnalyser::apply($out, 'cms-content', $all) === null, 'Second site scan was not idempotent'); // Removing exactly what was inserted must give back the original bytes. $restored = str_replace([' cms-content"', ' data-neo-image'], ['"', ''], $out); $restored = preg_replace('#^ {4}<(title|meta (name="description"|property="og:[a-z]+")).*\r\n#m', '', $restored); assertTrue($restored === $scanPage, 'Site scan changed bytes other than the inserted markers'); // Existing SEO tags are never overwritten; a page with no safe container is reported, not guessed. $seoPage = 'Mine

H

'; $seoOut = NeoCMS\SiteAnalyser::apply($seoPage, 'cms-content', ['seo' => true]); assertTrue($seoOut === null || (str_contains($seoOut['html'], 'Mine') && str_contains($seoOut['html'], 'content="Keep me"') && substr_count($seoOut['html'], 'name="description"') === 1), 'Existing SEO tags were overwritten'); assertTrue(NeoCMS\SiteAnalyser::apply('
' . str_repeat('text ', 30) . '
', 'cms-content', ['content' => true]) === null, 'A page without a safe container was tagged'); // Photo placeholders (role="img" boxes) outside a region are marked; icons, real images, and in-region boxes are not. $placeholderPage = '' . '
' . '
'; $placeholderOut = NeoCMS\SiteAnalyser::apply($placeholderPage, 'cms-content', ['images' => true]); assertTrue($placeholderOut !== null && substr_count($placeholderOut['html'], 'data-neo-image') === 3, 'Images-only pass should mark the portrait, the real image, and the placeholder in
'); assertTrue(str_contains($placeholderOut['html'], '