diff --git a/config/routes.yaml b/config/routes.yaml deleted file mode 100644 index 8e18de2..0000000 --- a/config/routes.yaml +++ /dev/null @@ -1,457 +0,0 @@ -# CMS Routes Configuration -# Admin routes with authentication - -routes: - # Authentication routes - login: - method: GET - route: /login - controller: Neuron\Cms\Controllers\Auth\Login@showLoginForm - - login_post: - method: POST - route: /login - controller: Neuron\Cms\Controllers\Auth\Login@login - - logout: - method: POST - route: /logout - controller: Neuron\Cms\Controllers\Auth\Login@logout - filters: [auth, csrf] - - # Password Reset routes - forgot_password: - method: GET - route: /forgot-password - controller: Neuron\Cms\Controllers\Auth\PasswordReset@showForgotPasswordForm - - forgot_password_post: - method: POST - route: /forgot-password - controller: Neuron\Cms\Controllers\Auth\PasswordReset@requestReset - - reset_password: - method: GET - route: /reset-password - controller: Neuron\Cms\Controllers\Auth\PasswordReset@showResetForm - - reset_password_post: - method: POST - route: /reset-password - controller: Neuron\Cms\Controllers\Auth\PasswordReset@resetPassword - - # Admin Dashboard - admin: - method: GET - route: /admin - controller: Neuron\Cms\Controllers\Admin\Dashboard@index - filter: auth - - admin_dashboard: - method: GET - route: /admin/dashboard - controller: Neuron\Cms\Controllers\Admin\Dashboard@index - filter: auth - - # User Management - admin_users: - method: GET - route: /admin/users - controller: Neuron\Cms\Controllers\Admin\Users@index - filter: auth - - admin_users_create: - method: GET - route: /admin/users/create - controller: Neuron\Cms\Controllers\Admin\Users@create - filter: auth - - admin_users_store: - method: POST - route: /admin/users - controller: Neuron\Cms\Controllers\Admin\Users@store - filters: [auth, csrf] - - admin_users_edit: - method: GET - route: /admin/users/:id/edit - controller: Neuron\Cms\Controllers\Admin\Users@edit - filter: auth - - admin_users_update: - method: PUT - route: /admin/users/:id - controller: Neuron\Cms\Controllers\Admin\Users@update - filters: [auth, csrf] - - admin_users_destroy: - method: DELETE - route: /admin/users/:id - controller: Neuron\Cms\Controllers\Admin\Users@destroy - filters: [auth, csrf] - - # Profile Management - admin_profile: - method: GET - route: /admin/profile - controller: Neuron\Cms\Controllers\Admin\Profile@edit - filter: auth - - admin_profile_update: - method: PUT - route: /admin/profile - controller: Neuron\Cms\Controllers\Admin\Profile@update - filters: [auth, csrf] - - # Blog Post Management - admin_posts: - method: GET - route: /admin/posts - controller: Neuron\Cms\Controllers\Admin\Posts@index - filter: auth - - admin_posts_create: - method: GET - route: /admin/posts/create - controller: Neuron\Cms\Controllers\Admin\Posts@create - filter: auth - - admin_posts_store: - method: POST - route: /admin/posts - controller: Neuron\Cms\Controllers\Admin\Posts@store - filters: [auth, csrf] - - admin_posts_edit: - method: GET - route: /admin/posts/:id/edit - controller: Neuron\Cms\Controllers\Admin\Posts@edit - filter: auth - - admin_posts_update: - method: PUT - route: /admin/posts/:id - controller: Neuron\Cms\Controllers\Admin\Posts@update - filters: [auth, csrf] - - admin_posts_destroy: - method: DELETE - route: /admin/posts/:id - controller: Neuron\Cms\Controllers\Admin\Posts@destroy - filters: [auth, csrf] - - # Blog Category Management - admin_categories: - method: GET - route: /admin/categories - controller: Neuron\Cms\Controllers\Admin\Categories@index - filter: auth - - admin_categories_create: - method: GET - route: /admin/categories/create - controller: Neuron\Cms\Controllers\Admin\Categories@create - filter: auth - - admin_categories_store: - method: POST - route: /admin/categories - controller: Neuron\Cms\Controllers\Admin\Categories@store - filters: [auth, csrf] - - admin_categories_edit: - method: GET - route: /admin/categories/:id/edit - controller: Neuron\Cms\Controllers\Admin\Categories@edit - filter: auth - - admin_categories_update: - method: PUT - route: /admin/categories/:id - controller: Neuron\Cms\Controllers\Admin\Categories@update - filters: [auth, csrf] - - admin_categories_destroy: - method: DELETE - route: /admin/categories/:id - controller: Neuron\Cms\Controllers\Admin\Categories@destroy - filters: [auth, csrf] - - # Blog Tag Management - admin_tags: - method: GET - route: /admin/tags - controller: Neuron\Cms\Controllers\Admin\Tags@index - filter: auth - - admin_tags_create: - method: GET - route: /admin/tags/create - controller: Neuron\Cms\Controllers\Admin\Tags@create - filter: auth - - admin_tags_store: - method: POST - route: /admin/tags - controller: Neuron\Cms\Controllers\Admin\Tags@store - filters: [auth, csrf] - - admin_tags_edit: - method: GET - route: /admin/tags/:id/edit - controller: Neuron\Cms\Controllers\Admin\Tags@edit - filter: auth - - admin_tags_update: - method: PUT - route: /admin/tags/:id - controller: Neuron\Cms\Controllers\Admin\Tags@update - filters: [auth, csrf] - - admin_tags_destroy: - method: DELETE - route: /admin/tags/:id - controller: Neuron\Cms\Controllers\Admin\Tags@destroy - filters: [auth, csrf] - - # Page Management - admin_pages: - method: GET - route: /admin/pages - controller: Neuron\Cms\Controllers\Admin\Pages@index - filter: auth - - admin_pages_create: - method: GET - route: /admin/pages/create - controller: Neuron\Cms\Controllers\Admin\Pages@create - filter: auth - - admin_pages_store: - method: POST - route: /admin/pages - controller: Neuron\Cms\Controllers\Admin\Pages@store - filters: [auth, csrf] - - admin_pages_edit: - method: GET - route: /admin/pages/:id/edit - controller: Neuron\Cms\Controllers\Admin\Pages@edit - filter: auth - - admin_pages_update: - method: PUT - route: /admin/pages/:id - controller: Neuron\Cms\Controllers\Admin\Pages@update - filters: [auth, csrf] - - admin_pages_destroy: - method: DELETE - route: /admin/pages/:id - controller: Neuron\Cms\Controllers\Admin\Pages@destroy - filters: [auth, csrf] - - # Media Management Routes - admin_media: - method: GET - route: /admin/media - controller: Neuron\Cms\Controllers\Admin\Media@index - filter: auth - - admin_media_upload: - method: POST - route: /admin/upload/image - controller: Neuron\Cms\Controllers\Admin\Media@uploadImage - filters: [auth, csrf] - - admin_media_featured_upload: - method: POST - route: /admin/upload/featured-image - controller: Neuron\Cms\Controllers\Admin\Media@uploadFeaturedImage - filters: [auth, csrf] - - # Homepage - home: - method: GET - route: / - controller: Neuron\Cms\Controllers\Home@index - - # Public Blog Routes - blog: - method: GET - route: /blog - controller: Neuron\Cms\Controllers\Blog@index - - blog_post: - method: GET - route: /blog/post/:slug - controller: Neuron\Cms\Controllers\Blog@show - - blog_category: - method: GET - route: /blog/category/:slug - controller: Neuron\Cms\Controllers\Blog@category - - blog_tag: - method: GET - route: /blog/tag/:slug - controller: Neuron\Cms\Controllers\Blog@tag - - blog_author: - method: GET - route: /blog/author/:username - controller: Neuron\Cms\Controllers\Blog@author - - rss: - method: GET - route: /rss - controller: Neuron\Cms\Controllers\Blog@feed - - # Public Pages - page_show: - method: GET - route: /pages/:slug - controller: Neuron\Cms\Controllers\Pages@show - - # Member Registration Routes - register: - method: GET - route: /register - controller: Neuron\Cms\Controllers\Member\Registration@showRegistrationForm - - register_post: - method: POST - route: /register - controller: Neuron\Cms\Controllers\Member\Registration@processRegistration - - verify_email: - method: GET - route: /verify-email - controller: Neuron\Cms\Controllers\Member\Registration@verify - - verify_email_sent: - method: GET - route: /verify-email-sent - controller: Neuron\Cms\Controllers\Member\Registration@showVerificationSent - - resend_verification: - method: POST - route: /resend-verification - controller: Neuron\Cms\Controllers\Member\Registration@resendVerification - filter: csrf - - # Member Dashboard Routes - member: - method: GET - route: /member - controller: Neuron\Cms\Controllers\Member\Dashboard@index - filter: member - - member_dashboard: - method: GET - route: /member/dashboard - controller: Neuron\Cms\Controllers\Member\Dashboard@index - filter: member - - # Member Profile Routes - member_profile: - method: GET - route: /member/profile - controller: Neuron\Cms\Controllers\Member\Profile@edit - filter: member - - member_profile_update: - method: PUT - route: /member/profile - controller: Neuron\Cms\Controllers\Member\Profile@update - filters: [member, csrf] - - # Event Category Management - admin_event_categories: - method: GET - route: /admin/event-categories - controller: Neuron\Cms\Controllers\Admin\EventCategories@index - filter: auth - - admin_event_categories_create: - method: GET - route: /admin/event-categories/create - controller: Neuron\Cms\Controllers\Admin\EventCategories@create - filter: auth - - admin_event_categories_store: - method: POST - route: /admin/event-categories - controller: Neuron\Cms\Controllers\Admin\EventCategories@store - filters: [auth, csrf] - - admin_event_categories_edit: - method: GET - route: /admin/event-categories/:id/edit - controller: Neuron\Cms\Controllers\Admin\EventCategories@edit - filter: auth - - admin_event_categories_update: - method: PUT - route: /admin/event-categories/:id - controller: Neuron\Cms\Controllers\Admin\EventCategories@update - filters: [auth, csrf] - - admin_event_categories_destroy: - method: DELETE - route: /admin/event-categories/:id - controller: Neuron\Cms\Controllers\Admin\EventCategories@destroy - filters: [auth, csrf] - - # Event Management - admin_events: - method: GET - route: /admin/events - controller: Neuron\Cms\Controllers\Admin\Events@index - filter: auth - - admin_events_create: - method: GET - route: /admin/events/create - controller: Neuron\Cms\Controllers\Admin\Events@create - filter: auth - - admin_events_store: - method: POST - route: /admin/events - controller: Neuron\Cms\Controllers\Admin\Events@store - filters: [auth, csrf] - - admin_events_edit: - method: GET - route: /admin/events/:id/edit - controller: Neuron\Cms\Controllers\Admin\Events@edit - filter: auth - - admin_events_update: - method: PUT - route: /admin/events/:id - controller: Neuron\Cms\Controllers\Admin\Events@update - filters: [auth, csrf] - - admin_events_destroy: - method: DELETE - route: /admin/events/:id - controller: Neuron\Cms\Controllers\Admin\Events@destroy - filters: [auth, csrf] - - # Public Calendar Routes - calendar: - method: GET - route: /calendar - controller: Neuron\Cms\Controllers\Calendar@index - - calendar_event: - method: GET - route: /calendar/event/:slug - controller: Neuron\Cms\Controllers\Calendar@show - - calendar_category: - method: GET - route: /calendar/category/:slug - controller: Neuron\Cms\Controllers\Calendar@category diff --git a/config/routing.yaml b/config/routing.yaml new file mode 100644 index 0000000..07563a2 --- /dev/null +++ b/config/routing.yaml @@ -0,0 +1,13 @@ +# Neuron CMS Routing Configuration (for CMS package development/testing) +# This file is used when testing the CMS package itself + +# URL Rewrites +# By default, the root path (/) is rewritten to /blog +rewrites: + '/': '/blog' + +# Controller Paths +# For CMS package development, point directly to src/Cms/Controllers +controller_paths: + - path: 'src/Cms/Controllers' + namespace: 'Neuron\Cms\Controllers' diff --git a/readme.md b/readme.md index 7a45d6a..f3d5597 100644 --- a/readme.md +++ b/readme.md @@ -104,7 +104,7 @@ The installer will: 1. Create the complete directory structure (app/, config/, db/, public/, resources/, storage/) 2. Publish all view templates (admin panel, blog, auth, layouts) 3. Publish application initializers -4. Create configuration files (neuron.yaml, routes.yaml, auth.yaml, event-listeners.yaml) +4. Create configuration files (neuron.yaml, routing.yaml, auth.yaml, event-listeners.yaml) 5. Generate the front controller (public/index.php) 6. Set up database migrations 7. Optionally run migrations to create database tables @@ -136,7 +136,7 @@ your-project/ │ ├── auth.yaml # Authentication configuration │ ├── neuron.yaml # Main application config │ ├── event-listeners.yaml # Event listener configuration -│ └── routes.yaml # Route definitions +│ └── routing.yaml # Routing configuration (URL rewrites, controller paths) │ ├── db/ │ ├── migrate/ # Database migrations @@ -224,7 +224,7 @@ database: name: storage/database.sqlite3 ``` -All routes, authentication settings, and event listeners are pre-configured by the installer. +All routing, authentication settings, and event listeners are pre-configured by the installer. ## Usage @@ -369,23 +369,74 @@ The installer creates `config/auth.yaml` with sensible defaults. You can customi - Failed login attempt limits - Account lockout duration -### Routes +### Routing -The installer automatically creates `config/routes.yaml` with pre-configured routes for: +The CMS uses attribute-based routing defined directly on controller methods. The installer creates `config/routing.yaml` to configure URL rewrites and controller paths. -- Public blog pages (`/blog`, `/blog/article/{slug}`, `/blog/category/{slug}`, `/blog/tag/{slug}`) -- Admin panel (`/admin/*`) -- Authentication (`/login`, `/logout`) -- Password reset (`/password/reset`, `/password/reset/confirm`) -- Member registration and dashboard: +#### URL Rewrites + +By default, the CMS rewrites the root URL (`/`) to `/blog`. You can customize this in `config/routing.yaml`: + +```yaml +# config/routing.yaml +rewrites: + '/': '/custom/landing' # Rewrite root to your custom controller + +controller_paths: + - path: 'app/Controllers' # Your controllers first (takes precedence) + namespace: 'App\Controllers' + - path: 'vendor/neuron-php/cms/src/Cms/Controllers' + namespace: 'Neuron\Cms\Controllers' +``` + +Then create your custom landing controller: + +```php +// app/Controllers/Landing.php +use Neuron\Mvc\Controller; +use Neuron\Routing\Attributes\Get; + +class Landing extends Controller +{ + #[Get('/custom/landing', name: 'landing')] + public function index() + { + return $this->renderHtml(OK, [], 'custom-home'); + } +} +``` + +URL rewrites are transparent (no HTTP redirect) - the browser URL stays the same while the application routes to a different path internally. + +#### Available Routes + +The CMS provides these pre-configured routes via controller attributes: + +- **Public blog pages**: + - `/blog` - Blog listing + - `/blog/article/:slug` - Individual post + - `/blog/category/:slug` - Category listing + - `/blog/tag/:slug` - Tag listing + - `/blog/rss` - RSS feed + +- **Admin panel**: `/admin/*` - Full admin interface with authentication + +- **Authentication**: + - `/login` - Login form + - `/logout` - Logout handler + +- **Password reset**: + - `/password/reset` - Request reset form + - `/password/reset/confirm` - Reset confirmation + +- **Member registration and dashboard**: - `/register` - Registration form - `/verify-email` - Email verification - `/resend-verification` - Resend verification email (rate-limited) - `/member` - Member dashboard (requires authentication) - `/member/profile` - Profile management -- RSS feed (`/blog/rss`) -You can customize routes by editing `config/routes.yaml`. +For more information about routing configuration, URL rewrites, and attribute-based routing, see the [MVC Routing Documentation](https://github.com/Neuron-PHP/mvc). ### Email diff --git a/resources/config/neuron.yaml.example b/resources/config/neuron.yaml.example index 2b527e3..84dca58 100644 --- a/resources/config/neuron.yaml.example +++ b/resources/config/neuron.yaml.example @@ -6,10 +6,8 @@ system: timezone: UTC # Server timezone (e.g., America/New_York, Europe/London, UTC) base_path: resources # Base path for resources -routing: - controller_paths: - - path: 'vendor/neuron-php/cms/src/Cms/Controllers' - namespace: 'Neuron\Cms\Controllers' +# Routing configuration is now in config/routing.yaml +# See routing.yaml for URL rewrites and controller path configuration logging: destination: \Neuron\Log\Destination\File # Log destination class diff --git a/resources/config/routing.yaml b/resources/config/routing.yaml new file mode 100644 index 0000000..85e4374 --- /dev/null +++ b/resources/config/routing.yaml @@ -0,0 +1,28 @@ +# Neuron CMS Routing Configuration +# This file configures URL rewrites and controller scanning for the CMS + +# URL Rewrites +# Transparent URL rewriting (no HTTP redirect) applied before route matching +# By default, the root path (/) is rewritten to /blog +rewrites: + '/': '/blog' + +# Controller Paths +# Directories to scan for controller classes with route attributes +# Order matters: controllers listed first take precedence for duplicate routes +controller_paths: + # Application controllers - your custom controllers go here + - path: 'app/Controllers' + namespace: 'App\Controllers' + + # CMS controllers - provides admin panel, blog, auth, etc. + - path: 'vendor/neuron-php/cms/src/Cms/Controllers' + namespace: 'Neuron\Cms\Controllers' + +# To customize the homepage: +# 1. Create your own controller in app/Controllers with a route for the desired path (e.g., /home) +# 2. Update the rewrite above to point '/' to your path +# +# Example: Custom homepage controller in app/Controllers/HomeController.php +# rewrites: +# '/': '/home' # Route to custom homepage diff --git a/src/Cms/Cli/Commands/Install/InstallCommand.php b/src/Cms/Cli/Commands/Install/InstallCommand.php index a819cf3..a6110e3 100644 --- a/src/Cms/Cli/Commands/Install/InstallCommand.php +++ b/src/Cms/Cli/Commands/Install/InstallCommand.php @@ -372,32 +372,26 @@ protected function createRouteConfig(): bool // Copy .gitignore template first $this->copyGitignoreTemplate(); - $routeFile = $this->_projectPath . '/config/routes.yaml'; - $resourceFile = $this->_componentPath . '/resources/config/routes.yaml'; + $routingFile = $this->_projectPath . '/config/routing.yaml'; + $resourceFile = $this->_componentPath . '/resources/config/routing.yaml'; - // If routes.yaml doesn't exist, copy from resources - if( !file_exists( $routeFile ) && file_exists( $resourceFile ) ) + // If routing.yaml doesn't exist, copy from resources + if( !file_exists( $routingFile ) && file_exists( $resourceFile ) ) { - if( copy( $resourceFile, $routeFile ) ) + if( copy( $resourceFile, $routingFile ) ) { - $this->_messages[] = "Created: config/routes.yaml"; + $this->_messages[] = "Created: config/routing.yaml"; return true; } - $this->output->error( " Failed to create routes.yaml" ); + $this->output->error( " Failed to create routing.yaml" ); return false; } - // If routes.yaml exists, check if it has admin routes - if( file_exists( $routeFile ) ) + // If routing.yaml exists, notify user + if( file_exists( $routingFile ) ) { - $content = file_get_contents( $routeFile ); - - if( strpos( $content, '/admin/dashboard' ) === false ) - { - $this->_messages[] = "⚠️ Please add admin routes to config/routes.yaml"; - $this->_messages[] = " See resources/config/routes.yaml for reference"; - } + $this->_messages[] = "✓ Routing config exists: config/routing.yaml"; } return true; @@ -882,18 +876,6 @@ protected function saveCompleteConfig( array $databaseConfig, array $appConfig, 'views' => [ 'path' => 'resources/views' ], - 'routing' => [ - 'controller_paths' => [ - [ - 'path' => 'app/Controllers', - 'namespace' => 'App\\Controllers' - ], - [ - 'path' => 'vendor/neuron-php/cms/src/Cms/Controllers', - 'namespace' => 'Neuron\\Cms\\Controllers' - ] - ] - ], 'system' => [ 'timezone' => $appConfig['timezone'], 'base_path' => $this->_projectPath diff --git a/src/Cms/Controllers/Home.php b/src/Cms/Controllers/Home.php deleted file mode 100644 index e000370..0000000 --- a/src/Cms/Controllers/Home.php +++ /dev/null @@ -1,68 +0,0 @@ -_registrationService = $registrationService; - } - - /** - * Display the homepage - * - * @param Request $request The HTTP request - * @return string Rendered HTML response - * @throws NotFound - */ - #[Get('/', name: 'home')] - public function index( Request $request ): string - { - // Check if registration is enabled - $registrationEnabled = $this->_registrationService->isRegistrationEnabled(); - - return $this->renderHtml( - HttpResponseStatus::OK, - [ - 'Title' => $this->getTitle(), - 'Name' => $this->getName(), - 'Description' => $this->getDescription(), - 'RegistrationEnabled' => $registrationEnabled, - ], - 'index' - ); - } -} diff --git a/tests/Unit/Cms/Controllers/HomeTest.php b/tests/Unit/Cms/Controllers/HomeTest.php deleted file mode 100644 index adf6af3..0000000 --- a/tests/Unit/Cms/Controllers/HomeTest.php +++ /dev/null @@ -1,186 +0,0 @@ -createMock( Router::class ); - $this->_mockApp = $this->createMock( IMvcApplication::class ); - $this->_mockApp->method( 'getRouter' )->willReturn( $router ); - - // Create version file in temp directory - $this->_versionFilePath = sys_get_temp_dir() . '/neuron-test-version-' . uniqid() . '.json'; - $versionContent = json_encode([ - 'major' => 1, - 'minor' => 0, - 'patch' => 0 - ]); - file_put_contents( $this->_versionFilePath, $versionContent ); - - // Create mock settings - $settings = new Memory(); - $settings->set( 'site', 'name', 'Test Site' ); - $settings->set( 'site', 'title', 'Test Title' ); - $settings->set( 'site', 'description', 'Test Description' ); - $settings->set( 'site', 'url', 'http://test.com' ); - $settings->set( 'paths', 'version_file', $this->_versionFilePath ); - - // Wrap in SettingManager - $this->_settingManager = new SettingManager( $settings ); - - // Store settings in registry - Registry::getInstance()->set( 'Settings', $this->_settingManager ); - } - - protected function tearDown(): void - { - // Clear registry - Registry::getInstance()->set( 'Settings', null ); - Registry::getInstance()->set( 'version', null ); - Registry::getInstance()->set( 'name', null ); - Registry::getInstance()->set( 'rss_url', null ); - Registry::getInstance()->set( 'DtoFactoryService', null ); - - // Clean up temp version file - if( isset( $this->_versionFilePath ) && file_exists( $this->_versionFilePath ) ) - { - unlink( $this->_versionFilePath ); - } - - parent::tearDown(); - } - - public function testConstructorWithRegistrationService(): void - { - $mockRegistrationService = $this->createMock( IRegistrationService::class ); - $mockSessionManager = $this->createMock( \Neuron\Cms\Auth\SessionManager::class ); - - $controller = new Home( $this->_mockApp, $this->_settingManager, $mockSessionManager, $mockRegistrationService ); - - $this->assertInstanceOf( Home::class, $controller ); - } - - public function testConstructorThrowsExceptionWithoutDependencies(): void - { - $this->expectException( \TypeError::class ); - // IMvcApplication is required (non-nullable), so TypeError will be thrown - - new Home( $this->_mockApp, null, null, null ); - } - - public function testIndexWithRegistrationEnabled(): void - { - $mockRegistrationService = $this->createMock( IRegistrationService::class ); - $mockRegistrationService->method( 'isRegistrationEnabled' )->willReturn( true ); - - $mockSessionManager = $this->createMock( \Neuron\Cms\Auth\SessionManager::class ); - - // Mock the controller to test renderHtml is called with correct params - $controller = $this->getMockBuilder( Home::class ) - ->setConstructorArgs( [ $this->_mockApp, $this->_settingManager, $mockSessionManager, $mockRegistrationService ] ) - ->onlyMethods( [ 'renderHtml' ] ) - ->getMock(); - - $controller->expects( $this->once() ) - ->method( 'renderHtml' ) - ->with( - $this->anything(), - $this->callback( function( $data ) { - return isset( $data['RegistrationEnabled'] ) && - $data['RegistrationEnabled'] === true && - isset( $data['Title'] ) && - isset( $data['Name'] ) && - isset( $data['Description'] ); - } ), - 'index' - ) - ->willReturn( 'Home Page' ); - - $request = new Request(); - $result = $controller->index( $request ); - - $this->assertEquals( 'Home Page', $result ); - } - - public function testIndexWithRegistrationDisabled(): void - { - $mockRegistrationService = $this->createMock( IRegistrationService::class ); - $mockRegistrationService->method( 'isRegistrationEnabled' )->willReturn( false ); - - $mockSessionManager = $this->createMock( \Neuron\Cms\Auth\SessionManager::class ); - - $controller = $this->getMockBuilder( Home::class ) - ->setConstructorArgs( [ $this->_mockApp, $this->_settingManager, $mockSessionManager, $mockRegistrationService ] ) - ->onlyMethods( [ 'renderHtml' ] ) - ->getMock(); - - $controller->expects( $this->once() ) - ->method( 'renderHtml' ) - ->with( - $this->anything(), - $this->callback( function( $data ) { - return isset( $data['RegistrationEnabled'] ) && - $data['RegistrationEnabled'] === false; - } ), - 'index' - ) - ->willReturn( 'Home Page' ); - - $request = new Request(); - $result = $controller->index( $request ); - - $this->assertEquals( 'Home Page', $result ); - } - - - public function testIndexPassesCorrectDataToView(): void - { - $mockRegistrationService = $this->createMock( IRegistrationService::class ); - $mockRegistrationService->method( 'isRegistrationEnabled' )->willReturn( true ); - - $mockSessionManager = $this->createMock( \Neuron\Cms\Auth\SessionManager::class ); - - $controller = $this->getMockBuilder( Home::class ) - ->setConstructorArgs( [ $this->_mockApp, $this->_settingManager, $mockSessionManager, $mockRegistrationService ] ) - ->onlyMethods( [ 'renderHtml' ] ) - ->getMock(); - - $controller->expects( $this->once() ) - ->method( 'renderHtml' ) - ->with( - $this->anything(), - $this->callback( function( $data ) { - // Verify all required keys are present - return isset( $data['Title'] ) && - isset( $data['Name'] ) && - isset( $data['Description'] ) && - isset( $data['RegistrationEnabled'] ) && - $data['RegistrationEnabled'] === true; - } ), - 'index' - ) - ->willReturn( 'Home Page' ); - - $request = new Request(); - $controller->index( $request ); - } -} diff --git a/tests/Unit/Cms/DuplicateRouteDetectionTest.php b/tests/Unit/Cms/DuplicateRouteDetectionTest.php new file mode 100644 index 0000000..e1f6644 --- /dev/null +++ b/tests/Unit/Cms/DuplicateRouteDetectionTest.php @@ -0,0 +1,184 @@ +router = new Router(); + + // Skip tests if routing component doesn't have new features yet + if( !method_exists( $this->router, 'setStrictMode' ) ) + { + $this->markTestSkipped( 'Routing component needs to be upgraded to support new features (setStrictMode, clearRoutes, route name parameter)' ); + } + } + + protected function tearDown(): void + { + // Reset router + $this->router = null; + parent::tearDown(); + } + + /** + * Test that duplicate route names are detected in strict mode + */ + public function testDuplicateRouteNameDetection() + { + $this->router->setStrictMode( true ); + + // Add first route with name + $this->router->get( '/users', function() { return 'first'; }, null, 'users.index' ); + + // Attempt to add second route with same name should throw + $this->expectException( DuplicateRouteException::class ); + $this->expectExceptionMessage( 'users.index' ); + + $this->router->post( '/users/create', function() { return 'second'; }, null, 'users.index' ); + } + + /** + * Test that setName() rollback works correctly + */ + public function testSetNameRollbackOnFailure() + { + $this->router->setStrictMode( true ); + + // Create route1 with name + $route1 = $this->router->get( '/first', function() {}, null, 'test.route' ); + $this->assertEquals( 'test.route', $route1->getName() ); + + // Create route2 with old name + $route2 = $this->router->get( '/second', function() {}, null, 'old.name' ); + $this->assertEquals( 'old.name', $route2->getName() ); + + // Try to rename route2 to existing name (should fail) + try + { + $route2->setName( 'test.route' ); + $this->fail( 'Should have thrown DuplicateRouteException' ); + } + catch( DuplicateRouteException $e ) + { + // Expected - verify rollback + $this->assertEquals( 'old.name', $route2->getName(), 'Route2 should still have old name after failed rename' ); + + // Verify old name is still registered to route2 + $foundRoute = $this->router->getRouteByName( 'old.name' ); + $this->assertSame( $route2, $foundRoute, 'old.name should still belong to route2' ); + } + } + + /** + * Test that duplicate path detection works in strict mode + */ + public function testDuplicatePathDetection() + { + $this->router->setStrictMode( true ); + + $this->router->get( '/users/:id', function() { return 'first'; } ); + + $this->expectException( DuplicateRouteException::class ); + $this->expectExceptionMessage( 'GET /users/:id' ); + + $this->router->get( '/users/:id', function() { return 'second'; } ); + } + + /** + * Test first-match-wins in non-strict mode + */ + public function testFirstMatchWinsInNonStrictMode() + { + $this->router->setStrictMode( false ); + + // Add first route + $route1 = $this->router->get( '/test', function() { return 'first'; } ); + + // Add duplicate (should not throw, first wins) + $route2 = $this->router->get( '/test', function() { return 'second'; } ); + + // Verify no exception thrown + $this->assertNotNull( $route1 ); + $this->assertNotNull( $route2 ); + + // Verify first route is matched + $matched = $this->router->getRoute( \Neuron\Routing\RequestMethod::GET, '/test' ); + $this->assertSame( $route1, $matched, 'First route should win in non-strict mode' ); + } + + /** + * Test enabling strict mode validates existing routes + */ + public function testEnablingStrictModeValidatesExisting() + { + // Add duplicates in non-strict mode + $this->router->setStrictMode( false ); + $this->router->get( '/duplicate', function() { return 'first'; } ); + $this->router->get( '/duplicate', function() { return 'second'; } ); + + // Enabling strict mode should detect the duplicates + $this->expectException( DuplicateRouteException::class ); + $this->router->setStrictMode( true ); + } + + /** + * Test clearRoutes() clears filter registry + */ + public function testClearRoutesAlsoClearsFilterRegistry() + { + // Register a filter + $filter = new \Neuron\Routing\Filter( function() { return true; }, null ); + $this->router->registerFilter( 'test-filter', $filter ); + + // Add a route + $this->router->get( '/test', function() {} ); + + // Clear routes + $this->router->clearRoutes(); + + // Verify filter registry was cleared + $this->expectException( \Exception::class ); + $this->expectExceptionMessage( 'Filter test-filter not registered' ); + + $this->router->getFilter( 'test-filter' ); + } + + /** + * Test RESTful routes with same path but different methods (should be allowed) + */ + public function testRestfulRoutesAllowed() + { + $this->router->setStrictMode( true ); + + // These should all be allowed (different HTTP methods) + $route1 = $this->router->get( '/users', function() {}, null, 'users.index' ); + $route2 = $this->router->post( '/users', function() {}, null, 'users.store' ); + $route3 = $this->router->put( '/users/:id', function() {}, null, 'users.update' ); + $route4 = $this->router->delete( '/users/:id', function() {}, null, 'users.destroy' ); + + $this->assertNotNull( $route1 ); + $this->assertNotNull( $route2 ); + $this->assertNotNull( $route3 ); + $this->assertNotNull( $route4 ); + } +} diff --git a/tests/Unit/Cms/RoutingConfigTest.php b/tests/Unit/Cms/RoutingConfigTest.php new file mode 100644 index 0000000..759b6e2 --- /dev/null +++ b/tests/Unit/Cms/RoutingConfigTest.php @@ -0,0 +1,134 @@ +reset(); + } + + protected function tearDown(): void + { + Registry::getInstance()->reset(); + parent::tearDown(); + } + + /** + * Test that null value correctly triggers fallback to neuron.yaml + */ + public function testNullControllerPathsTriggersNeuronYamlFallback() + { + // Simulate routing.yaml not setting controller_paths (null in Registry) + $controllerPaths = Registry::getInstance()->get( 'Routing.ControllerPaths' ); + + $this->assertNull( $controllerPaths, 'Controller paths should be null when not set' ); + + // Verify the strict null check would trigger fallback + $shouldFallback = ( $controllerPaths === null ); + $this->assertTrue( $shouldFallback, 'Null should trigger fallback to neuron.yaml' ); + } + + /** + * Test that empty array is respected and doesn't trigger fallback + */ + public function testEmptyArrayControllerPathsDoesNotFallback() + { + // Simulate routing.yaml explicitly setting controller_paths: [] + Registry::getInstance()->set( 'Routing.ControllerPaths', [] ); + + $controllerPaths = Registry::getInstance()->get( 'Routing.ControllerPaths' ); + + $this->assertIsArray( $controllerPaths, 'Controller paths should be an array' ); + $this->assertCount( 0, $controllerPaths, 'Controller paths should be empty array' ); + + // Verify the strict null check would NOT trigger fallback + $shouldFallback = ( $controllerPaths === null ); + $this->assertFalse( $shouldFallback, 'Empty array should NOT trigger fallback' ); + } + + /** + * Test that populated array from routing.yaml is used + */ + public function testPopulatedControllerPathsFromRoutingYaml() + { + // Simulate routing.yaml with controller_paths + $paths = [ + [ + 'path' => 'vendor/neuron-php/cms/src/Cms/Controllers', + 'namespace' => 'Neuron\Cms\Controllers' + ] + ]; + + Registry::getInstance()->set( 'Routing.ControllerPaths', $paths ); + + $controllerPaths = Registry::getInstance()->get( 'Routing.ControllerPaths' ); + + $this->assertIsArray( $controllerPaths ); + $this->assertCount( 1, $controllerPaths ); + $this->assertEquals( 'Neuron\Cms\Controllers', $controllerPaths[0]['namespace'] ); + + // Verify the strict null check would NOT trigger fallback + $shouldFallback = ( $controllerPaths === null ); + $this->assertFalse( $shouldFallback, 'Populated array should NOT trigger fallback' ); + } + + /** + * Test the old buggy behavior (for documentation) + */ + public function testDemonstrateBuggyBehaviorWithEmptyArray() + { + $emptyArray = []; + $nullValue = null; + + // OLD BUGGY CHECK: !$controllerPaths + $buggyCheckWithEmpty = !$emptyArray; // evaluates to true (WRONG!) + $buggyCheckWithNull = !$nullValue; // evaluates to true (correct) + + $this->assertTrue( $buggyCheckWithEmpty, 'Old check: ![] incorrectly evaluates to true' ); + $this->assertTrue( $buggyCheckWithNull, 'Old check: !null correctly evaluates to true' ); + + // NEW CORRECT CHECK: $controllerPaths === null + $correctCheckWithEmpty = ( $emptyArray === null ); // evaluates to false (correct!) + $correctCheckWithNull = ( $nullValue === null ); // evaluates to true (correct) + + $this->assertFalse( $correctCheckWithEmpty, 'New check: [] === null correctly evaluates to false' ); + $this->assertTrue( $correctCheckWithNull, 'New check: null === null correctly evaluates to true' ); + } + + /** + * Test routing.yaml precedence scenarios + */ + public function testRoutingYamlPrecedenceScenarios() + { + // Scenario 1: routing.yaml doesn't exist (null) + Registry::getInstance()->reset(); + $scenario1 = Registry::getInstance()->get( 'Routing.ControllerPaths' ); + $this->assertNull( $scenario1, 'Scenario 1: No routing.yaml should be null' ); + + // Scenario 2: routing.yaml exists with empty array + Registry::getInstance()->set( 'Routing.ControllerPaths', [] ); + $scenario2 = Registry::getInstance()->get( 'Routing.ControllerPaths' ); + $this->assertSame( [], $scenario2, 'Scenario 2: Empty array should be respected' ); + + // Scenario 3: routing.yaml exists with paths + $paths = [['path' => 'src/Controllers', 'namespace' => 'App\Controllers']]; + Registry::getInstance()->set( 'Routing.ControllerPaths', $paths ); + $scenario3 = Registry::getInstance()->get( 'Routing.ControllerPaths' ); + $this->assertSame( $paths, $scenario3, 'Scenario 3: Paths should be used' ); + } +} diff --git a/tests/Unit/Cms/UrlRewriteSanitizationTest.php b/tests/Unit/Cms/UrlRewriteSanitizationTest.php new file mode 100644 index 0000000..eb504fa --- /dev/null +++ b/tests/Unit/Cms/UrlRewriteSanitizationTest.php @@ -0,0 +1,251 @@ +logDestination = new Memory( $format ); + + $logger = new \Neuron\Log\Logger( $this->logDestination ); + $logger->setRunLevel( \Neuron\Log\RunLevel::DEBUG ); + + // Get Log singleton and add our logger + $log = Log::getInstance(); + $log->initIfNeeded(); + $log->logger->addLog( $logger ); + + // Set global run level to DEBUG + Log::setRunLevel( \Neuron\Log\RunLevel::DEBUG ); + + $this->router = new Router(); + + // Skip tests if routing component doesn't have URL sanitization yet + if( !method_exists( $this->router, 'setUrlRewrites' ) ) + { + $this->markTestSkipped( 'Routing component needs to be upgraded to support URL rewrites' ); + } + } + + protected function tearDown(): void + { + $this->router = null; + + // Clean up logging + $log = Log::getInstance(); + if( $log->logger ) + { + $log->logger->reset(); + } + + parent::tearDown(); + } + + /** + * Test that query strings are stripped from logged URLs + */ + public function testQueryStringsNotLogged() + { + // Configure a rewrite rule + $this->router->setUrlRewrites([ + '/api' => '/api/v2' + ]); + + // Create a route that will match after rewrite + $this->router->get( '/api/v2', function() { return 'api v2'; } ); + + // Run the router with a URL containing sensitive query params + try + { + $this->router->run([ + 'route' => '/api?token=secret123&api_key=abc456', + 'type' => 'GET' + ]); + } + catch( \Exception $e ) + { + // May throw due to routing issues, but we only care about logs + } + + // Get log entries (split by newlines) + $logData = $this->logDestination->getData(); + $this->assertIsString( $logData, 'Log data should be a string' ); + + $logs = array_filter( explode( "\n", $logData ) ); + $debugLogs = array_filter( $logs, function( $entry ) { + return stripos( $entry, 'URL rewrite' ) !== false; + }); + + // If no URL rewrite logs found, skip test (feature not yet implemented) + if( count( $debugLogs ) === 0 ) + { + $this->markTestSkipped( 'URL rewrite logging not yet implemented in routing component' ); + } + + // Verify query strings are not in the logs + foreach( $debugLogs as $log ) + { + $this->assertStringNotContainsString( 'token=', $log, 'Sensitive token should not be logged' ); + $this->assertStringNotContainsString( 'secret123', $log, 'Token value should not be logged' ); + $this->assertStringNotContainsString( 'api_key=', $log, 'API key parameter should not be logged' ); + $this->assertStringNotContainsString( 'abc456', $log, 'API key value should not be logged' ); + $this->assertStringNotContainsString( '?', $log, 'Query string delimiter should not be logged' ); + } + + // Verify the path portion IS logged + $logEntry = array_values( $debugLogs )[0]; + $this->assertStringContainsString( '/api', $logEntry, 'Path should be logged' ); + } + + /** + * Test that URL fragments are stripped from logged URLs + */ + public function testFragmentsNotLogged() + { + $this->router->setUrlRewrites([ + '/page' => '/pages/show' + ]); + + $this->router->get( '/pages/show', function() { return 'page'; } ); + + try + { + $this->router->run([ + 'route' => '/page#section-with-sensitive-data', + 'type' => 'GET' + ]); + } + catch( \Exception $e ) + { + // May throw, but we only care about logs + } + + $logData = $this->logDestination->getData(); + $this->assertIsString( $logData, 'Log data should be a string' ); + + $logs = array_filter( explode( "\n", $logData ) ); + $debugLogs = array_filter( $logs, function( $entry ) { + return stripos( $entry, 'URL rewrite' ) !== false; + }); + + // If no URL rewrite logs found, skip test (feature not yet implemented) + if( count( $debugLogs ) === 0 ) + { + $this->markTestSkipped( 'URL rewrite logging not yet implemented in routing component' ); + } + + foreach( $debugLogs as $log ) + { + $this->assertStringNotContainsString( '#', $log, 'Fragment delimiter should not be logged' ); + $this->assertStringNotContainsString( 'section-with-sensitive', $log, 'Fragment content should not be logged' ); + } + } + + /** + * Test clean URLs (no query/fragment) are logged normally + */ + public function testCleanUrlsLoggedNormally() + { + $this->router->setUrlRewrites([ + '/' => '/blog' + ]); + + $this->router->get( '/blog', function() { return 'blog'; } ); + + try + { + $this->router->run([ + 'route' => '/', + 'type' => 'GET' + ]); + } + catch( \Exception $e ) + { + // May throw, but we only care about logs + } + + $logData = $this->logDestination->getData(); + $logs = array_filter( explode( "\n", $logData ) ); + $debugLogs = array_filter( $logs, function( $entry ) { + return stripos( $entry, 'URL rewrite: / ->' ) !== false; + }); + + // Clean URL should be logged + $this->assertGreaterThan( 0, count( $debugLogs ), 'Rewrite should be logged' ); + + $logEntry = array_values( $debugLogs )[0]; + $this->assertStringContainsString( '/', $logEntry, 'Root path should be logged' ); + $this->assertStringContainsString( '/blog', $logEntry, 'Rewrite target should be logged' ); + } + + /** + * Test that both query strings AND fragments are stripped + */ + public function testBothQueryAndFragmentStripped() + { + $this->router->setUrlRewrites([ + '/search' => '/search/results' + ]); + + $this->router->get( '/search/results', function() { return 'results'; } ); + + try + { + $this->router->run([ + 'route' => '/search?q=password&user=admin#results', + 'type' => 'GET' + ]); + } + catch( \Exception $e ) + { + // May throw, but we only care about logs + } + + $logData = $this->logDestination->getData(); + $this->assertIsString( $logData, 'Log data should be a string' ); + + $logs = array_filter( explode( "\n", $logData ) ); + $debugLogs = array_filter( $logs, function( $entry ) { + return stripos( $entry, 'URL rewrite' ) !== false; + }); + + // If no URL rewrite logs found, skip test (feature not yet implemented) + if( count( $debugLogs ) === 0 ) + { + $this->markTestSkipped( 'URL rewrite logging not yet implemented in routing component' ); + } + + foreach( $debugLogs as $log ) + { + // Neither query string nor fragment should appear + $this->assertStringNotContainsString( '?', $log ); + $this->assertStringNotContainsString( '#', $log ); + $this->assertStringNotContainsString( 'password', $log ); + $this->assertStringNotContainsString( 'admin', $log ); + $this->assertStringNotContainsString( 'results', $log ); // fragment content + + // But path should be there + $this->assertStringContainsString( '/search', $log ); + } + } +}