diff --git a/.github/workflows/dusk.yml b/.github/workflows/dusk.yml new file mode 100644 index 0000000000..b39d471d59 --- /dev/null +++ b/.github/workflows/dusk.yml @@ -0,0 +1,93 @@ +name: Dusk +on: [push] +jobs: + + dusk-php: + runs-on: ubuntu-latest + env: + APP_URL: "http://127.0.0.1:8000" + APP_ENV: dusk + APP_CORS_ALLOWED_ORIGINS: "*" + DB_USERNAME: root + DB_PASSWORD: root + MAIL_MAILER: log + APP_KEY: "base64:8hOaU5CSjb45bxnFEToJwOsfhOpOvH/g4OWcoJPNyyE=" + RECAPTCHA_ENABLED: false + steps: + - uses: actions/checkout@v3 + - name: Prepare The Environment + run: cp .env.example .env + - name: Create Database + run: | + sudo systemctl start mysql + mysql --user="root" --password="root" -e "CREATE DATABASE \`panel\` character set UTF8mb4 collate utf8mb4_bin;" + + - name: Get Cache Directory + id: composer-cache + run: | + echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + - name: Cache Composer + uses: actions/cache@v3 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-8.1-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-composer-8.1- + + - name: Install Composer Dependencies + run: composer install --no-interaction --no-progress --prefer-dist --optimize-autoloader + + - name: Generate Application Key + run: php artisan key:generate --force --no-interaction + + - name: Setup Node + uses: actions/setup-node@v3 + with: + node-version: 16 + cache: "yarn" + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Build + run: yarn build:production + + - name: Install Redis + run: sudo apt-get install redis-server + - name: Start Redis Server + run: redis-server --daemonize yes + - name: Check Redis Server + run: redis-cli ping + + - name: Run Laravel Server + run: php artisan serve --no-reload & + + - name: Run Laravel Server Pseudo Daemon + run: php artisan serve --no-reload & + + - name: Upgrade Chrome Driver + run: php artisan dusk:chrome-driver --detect + - name: Start Chrome Driver + run: ./vendor/laravel/dusk/bin/chromedriver-linux & + + - name: Run Dusk Tests + run: php artisan dusk + + - name: Upload Screenshots + if: failure() + uses: actions/upload-artifact@v3 + with: + name: browser-screenshots + path: tests/Browser/screenshots + - name: Upload Console Logs + if: failure() + uses: actions/upload-artifact@v3 + with: + name: browser-console + path: tests/Browser/console + - name: Upload Application Logs + if: failure() + uses: actions/upload-artifact@v3 + with: + name: storage-logs + path: storage/logs diff --git a/.gitignore b/.gitignore index 2c9fda4ca6..3354ea647f 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ resources/lang/locales.js /public/hot result docker-compose.yaml +.phpunit.cache diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index b7085d9ed8..4c905516da 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -90,6 +90,7 @@ class Kernel extends HttpKernel 'auth' => Authenticate::class, 'auth.basic' => AuthenticateWithBasicAuth::class, 'auth.session' => AuthenticateSession::class, + 'cors' => HandleCors::class, 'guest' => RedirectIfAuthenticated::class, 'csrf' => VerifyCsrfToken::class, 'throttle' => ThrottleRequests::class, diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 43c99f82e9..1355fbd604 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -62,6 +62,8 @@ public function boot(): void ->prefix('/api/remote') ->scopeBindings() ->group(base_path('routes/api-remote.php')); + + $this->duskBoot(); }); } @@ -107,4 +109,25 @@ protected function configureRateLimiting(): void )->by($key); }); } + + // Laravel Dusk Browser Testing Route Helpers for the Daemon + private function duskBoot() + { + // Make sure we're only running in the Dusk testing environment + if (!app()->environment('dusk')) { + return; + } + + // Simulate Node Ping + Route::get('/api/system', fn () => [ + 'version' => '1.7.0', + 'kernel_version' => '5.4.0-126-generic', + 'architecture' => 'amd64', + 'os' => 'linux', + 'cpu_count' => 2, + ]); + + // Simulate Successful Server Creation + Route::post('/api/servers', fn () => []); + } } diff --git a/composer.json b/composer.json index 25595571f0..f0a255d21a 100644 --- a/composer.json +++ b/composer.json @@ -58,6 +58,7 @@ "fakerphp/faker": "~1.21.0", "friendsofphp/php-cs-fixer": "~3.14.4", "itsgoingd/clockwork": "~5.1.12", + "laravel/dusk": "^7.12", "laravel/sail": "~1.21.0", "mockery/mockery": "~1.5.1", "nunomaduro/collision": "~7.0.5", diff --git a/composer.lock b/composer.lock index e1d83de066..7a241757df 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c825c79676768901c780446d7550a7f6", + "content-hash": "d9326ab9c1f29c749d0683c32f4667b6", "packages": [ { "name": "aws/aws-crt-php", @@ -8719,6 +8719,82 @@ ], "time": "2022-12-13T00:04:12+00:00" }, + { + "name": "laravel/dusk", + "version": "v7.12.3", + "source": { + "type": "git", + "url": "https://github.com/laravel/dusk.git", + "reference": "676df11326c29d11ee566cd046f8e49e0ce6eb0a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/dusk/zipball/676df11326c29d11ee566cd046f8e49e0ce6eb0a", + "reference": "676df11326c29d11ee566cd046f8e49e0ce6eb0a", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-zip": "*", + "guzzlehttp/guzzle": "^7.2", + "illuminate/console": "^9.0|^10.0", + "illuminate/support": "^9.0|^10.0", + "nesbot/carbon": "^2.0", + "php": "^8.0", + "php-webdriver/webdriver": "^1.9.0", + "symfony/console": "^6.0", + "symfony/finder": "^6.0", + "symfony/process": "^6.0", + "vlucas/phpdotenv": "^5.2" + }, + "require-dev": { + "mockery/mockery": "^1.4.2", + "orchestra/testbench": "^7.33|^8.13", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5.10|^10.0.1", + "psy/psysh": "^0.11.12" + }, + "suggest": { + "ext-pcntl": "Used to gracefully terminate Dusk when tests are running." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + }, + "laravel": { + "providers": [ + "Laravel\\Dusk\\DuskServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Dusk\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Dusk provides simple end-to-end testing and browser automation.", + "keywords": [ + "laravel", + "testing", + "webdriver" + ], + "support": { + "issues": "https://github.com/laravel/dusk/issues", + "source": "https://github.com/laravel/dusk/tree/v7.12.3" + }, + "time": "2024-02-15T13:38:58+00:00" + }, { "name": "laravel/sail", "version": "v1.21.0", @@ -9114,6 +9190,72 @@ }, "time": "2022-02-21T01:04:05+00:00" }, + { + "name": "php-webdriver/webdriver", + "version": "1.15.1", + "source": { + "type": "git", + "url": "https://github.com/php-webdriver/php-webdriver.git", + "reference": "cd52d9342c5aa738c2e75a67e47a1b6df97154e8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-webdriver/php-webdriver/zipball/cd52d9342c5aa738c2e75a67e47a1b6df97154e8", + "reference": "cd52d9342c5aa738c2e75a67e47a1b6df97154e8", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-zip": "*", + "php": "^7.3 || ^8.0", + "symfony/polyfill-mbstring": "^1.12", + "symfony/process": "^5.0 || ^6.0 || ^7.0" + }, + "replace": { + "facebook/webdriver": "*" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.20.0", + "ondram/ci-detector": "^4.0", + "php-coveralls/php-coveralls": "^2.4", + "php-mock/php-mock-phpunit": "^2.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpunit/phpunit": "^9.3", + "squizlabs/php_codesniffer": "^3.5", + "symfony/var-dumper": "^5.0 || ^6.0" + }, + "suggest": { + "ext-SimpleXML": "For Firefox profile creation" + }, + "type": "library", + "autoload": { + "files": [ + "lib/Exception/TimeoutException.php" + ], + "psr-4": { + "Facebook\\WebDriver\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP client for Selenium WebDriver. Previously facebook/webdriver.", + "homepage": "https://github.com/php-webdriver/php-webdriver", + "keywords": [ + "Chromedriver", + "geckodriver", + "php", + "selenium", + "webdriver" + ], + "support": { + "issues": "https://github.com/php-webdriver/php-webdriver/issues", + "source": "https://github.com/php-webdriver/php-webdriver/tree/1.15.1" + }, + "time": "2023-10-20T12:21:20+00:00" + }, { "name": "phpdocumentor/reflection-common", "version": "2.2.0", @@ -11181,5 +11323,5 @@ "platform-overrides": { "php": "8.1.0" }, - "plugin-api-version": "2.3.0" + "plugin-api-version": "2.6.0" } diff --git a/config/cors.php b/config/cors.php index bf72895e06..99fb9e34ed 100644 --- a/config/cors.php +++ b/config/cors.php @@ -18,7 +18,7 @@ * You can enable CORS for 1 or multiple paths. * Example: ['api/*'] */ - 'paths' => ['/api/client', '/api/application', '/api/client/*', '/api/application/*'], + 'paths' => ['/api/client', '/api/application', '/api/client/*', '/api/application/*', '/api/servers', '/api/system'], /* * Matches the request method. `['*']` allows all methods. diff --git a/tests/Browser/MainTest.php b/tests/Browser/MainTest.php new file mode 100644 index 0000000000..87fcef2f37 --- /dev/null +++ b/tests/Browser/MainTest.php @@ -0,0 +1,228 @@ +create([ + 'email' => $login, + 'password' => Hash::make($pass), + 'root_admin' => true, + 'name_first' => 'Lance', + 'name_last' => 'Dactyl', + ]); + + // Seed initial eggs + $this->artisan('migrate --seed --force'); + + $this->browse(function (Browser $browser) use ($login, $pass) { + [$panelProtocol, $panelUrl] = explode('://', config('app.url'), 2); + + $panelDomain = $panelUrl; + if (str_contains($panelUrl, ':')) { + [$panelDomain, $panelPort] = explode(':', $panelUrl, 2); + } + + // Default to HTTP if not specified + $panelPort = intval($panelPort ?? 80); + + // For CI, use next port + if ($panelPort !== 80) { + ++$panelPort; + } + + // Test Failed Login + $browser->visit(new Login()) + ->submit($login, 'incorrect') + ->waitFor('@alert', 2) + ->assertSeeIn('@alert', 'ERROR'); + + // Test Successful Login + $browser->visit(new Login()) + ->submit($login, $pass) + ->waitForReload() + ->assertPathIs('/'); + + // Test No Servers + $browser->assertMissing('section div>a'); + + // Click on Admin Dashboard /admin and see no redirect or not denied access + $browser->visit('/admin'); + $browser->assertPathIs('/admin'); + $browser->assertDontSee('Forbidden'); + $browser->assertSee('Admin'); + + // Create new non administrator user and see success + $browser->visit(new CreateUser()) + ->create('matthew@example.com', 'bird', 'mypasswordiscooler', 'Matthew', 'Dactyl') + ->assertPathIs('/admin/users/view/2'); + + // Try to create duplicate user and see failure + $browser->visit(new CreateUser()) + ->create('matthew@example.com', 'bird', 'mypasswordiscool', 'Matthew', 'Dactyl') + ->assertSee('There was an error') + ->assertPathIs('/admin/users/new'); + + // Click on Locations in navigation and then click on Create New + $browser->visit('/admin/locations'); + $browser->assertSee('Create New'); + $browser->click('button[data-target="#newLocationModal"]'); + $browser->waitFor('.modal-dialog', 3); + + // Create New Location successfully + $browser->type('short', 'us'); + $browser->type('long', 'Number one exporter of potassium'); + $browser->clickAndWaitForReload('button[type=submit]'); + $browser->assertPathIs('/admin/locations/view/1'); + + // Click on Nodes in navigation and then create a new Node successfully + $browser->visit('/admin/nodes/new'); + $browser->type('name', 'noderize'); + $browser->type('description', 'my server is the best'); + $browser->select('location_id', '1'); + $browser->type('fqdn', $panelDomain); + $browser->click('label[for=pSSLFalse]'); // radio http + $browser->type('memory', '1024'); + $browser->type('memory_overallocate', '0'); + $browser->type('disk', '1024'); + $browser->type('disk_overallocate', '0'); + $browser->type('daemonListen', $panelPort); + $browser->clickAndWaitForReload('button[type=submit]'); + $browser->assertPathIs('/admin/nodes/view/1/allocation'); + + // Create 3 new dummy allocations successfully in the same Node + $browser->waitForText('Assign New Allocations'); + $browser->type('select[name="allocation_ip"] + span.select2 input[type="search"]', '127.0.0.1'); + $browser->type('select[name="allocation_ports[]"] + span.select2 input[type="search"]', '1234 '); + $browser->type('select[name="allocation_ports[]"] + span.select2 input[type="search"]', '2345 '); + $browser->type('select[name="allocation_ports[]"] + span.select2 input[type="search"]', '3456'); + $browser->clickAndWaitForReload('button[type=submit]'); + $browser->assertPathIs('/admin/nodes/view/1/allocation'); + $browser->assertSeeIn('table', '1234'); + $browser->assertSeeIn('table', '2345'); + $browser->assertSeeIn('table', '3456'); + + // See that the heartbeat is green/success + $browser->visit('/admin/nodes'); + $browser->waitFor('table .fa-heartbeat', 5); + + // Create New Node successfully + $browser->visit('/admin/nodes/new'); + $browser->type('name', 'antinode'); + $browser->type('description', 'my server broke :('); + $browser->select('location_id', '1'); + $browser->type('fqdn', $panelDomain); + $browser->click('label[for=pSSLFalse]'); // radio http + $browser->type('memory', '1024'); + $browser->type('memory_overallocate', '0'); + $browser->type('disk', '1024'); + $browser->type('disk_overallocate', '0'); + $browser->type('daemonListen', '9001'); + $browser->clickAndWaitForReload('button[type=submit]'); + $browser->assertPathIs('/admin/nodes/view/2/allocation'); + + // Go back to /admin/nodes and see the heartbeat is red/failing + $browser->visit('/admin/nodes'); + $browser->waitFor('table .fa-heart-o', 5); + + $servers = [ + 'names' => ['apple', 'banana', 'cherry'], + 'owners' => ['Lance', 'Lance', 'Matthew'], + ]; + + // Create 3 New Servers successfully + for ($i = 0; $i < 3; ++$i) { + // Click on Servers in navigation and then click on Create New + $browser->visit('/admin/servers/new'); + $browser->type('name', $servers['names'][$i]); + $browser->click('select[name=owner_id] + .select2'); + $browser->waitFor('script + .select2-container input[type=search]'); + $browser->type('script + .select2-container input[type=search]', $servers['owners'][$i]); + $browser->waitForTextIn('.username', $servers['owners'][$i], 3); + $browser->click('.user-block'); + $browser->type('description', 'Yay a server'); + $browser->type('memory', '1024'); + $browser->type('disk', '1024'); + $browser->clickAndWaitForReload('input[type=submit]'); + $browser->assertPathIs('/admin/servers/view/' . ($i + 1)); + } + + // Exit Admin Panel and see two servers + $browser->visit('/'); + $browser->waitForText('SERVERS'); + $browser->assertSee('apple'); + $browser->assertSee('banana'); + $browser->assertDontSee('cherry'); + $browser->assertDontSee('There are no other servers to display.'); + + // Click the toggle and see the final one not owned by the admin + $browser->click('input[name=show_all_servers] + label'); + $browser->waitForText('cherry'); + $browser->assertSee('cherry'); + $browser->assertDontSee('apple'); + $browser->assertDontSee('banana'); + + // Switch back to the owned servers + $browser->click('input[name=show_all_servers] + label'); + $browser->waitForText('banana'); + + /** @var Server $server */ + $server = Server::query()->findOrFail(2); + $server->update(['status' => null]); + + // Click on the middle server and then click on Users in the navigation + $browser->click("a[href='/server/$server->uuidShort']"); + $browser->waitForText('banana'); + $browser->click("a[href='/server/$server->uuidShort/users']"); + $browser->waitForText("It looks like you don't have any subusers."); + + // Click on New User and enter the same email as the non admin user (full permissions) + $browser->click('section button'); + $browser->waitForText('Create new subuser'); + $browser->click('input[type=checkbox]'); + $browser->type('email', 'matthew@example.com'); + $browser->assertDontSee('A valid email address must be provided.'); + $browser->click('button[type=submit]'); + $browser->waitFor('button[aria-label="Edit subuser"]'); + $browser->assertSee('matthew@example.com'); + + // Click on logout and see redirect back to login screen + $browser->clickAndWaitForReload('#logo + div button'); + $browser->assertPathIs('/auth/login'); + + // Login as the non admin user successfully + $browser->type('username', 'matthew@example.com'); + $browser->type('password', 'mypasswordiscooler'); + $browser->clickAndWaitForReload('button[type=submit]'); + $browser->assertPathIs('/'); + $browser->waitForText('127.0.0.1'); + + // See both owned server and unowned + $browser->assertDontSee('apple'); + $browser->assertSee('banana'); + $browser->assertSee('cherry'); + }); + } +} diff --git a/tests/Browser/Pages/CreateUser.php b/tests/Browser/Pages/CreateUser.php new file mode 100644 index 0000000000..135c01057a --- /dev/null +++ b/tests/Browser/Pages/CreateUser.php @@ -0,0 +1,36 @@ +assertPathIs($this->url()); + } + + public function elements() + { + return [ + '@submit' => '[type=submit]', + ]; + } + + public function create(Browser $browser, $email, $username, $password, $firstName, $lastName) + { + $browser->type('email', $email); + $browser->type('username', $username); + $browser->type('name_first', $firstName); + $browser->type('name_last', $lastName); + $browser->type('password', $password); + $browser->clickAndWaitForReload('@submit', 3); + } +} diff --git a/tests/Browser/Pages/Login.php b/tests/Browser/Pages/Login.php new file mode 100644 index 0000000000..cc7eeeeef7 --- /dev/null +++ b/tests/Browser/Pages/Login.php @@ -0,0 +1,35 @@ + '[type=submit]', + '@alert' => '[role=alert]', + ]; + } + + public function assert(Browser $browser) + { + $browser->assertPathIs($this->url()); + } + + public function submit(Browser $browser, string $username, string $password) + { + $browser->type('username', $username); + $browser->type('password', $password); + + $browser->click('@submit'); + } +} diff --git a/tests/Browser/Pages/Page.php b/tests/Browser/Pages/Page.php new file mode 100644 index 0000000000..c5d07c0890 --- /dev/null +++ b/tests/Browser/Pages/Page.php @@ -0,0 +1,20 @@ + '#selector', + ]; + } +} diff --git a/tests/Browser/console/.gitignore b/tests/Browser/console/.gitignore new file mode 100644 index 0000000000..d6b7ef32c8 --- /dev/null +++ b/tests/Browser/console/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/tests/Browser/screenshots/.gitignore b/tests/Browser/screenshots/.gitignore new file mode 100644 index 0000000000..d6b7ef32c8 --- /dev/null +++ b/tests/Browser/screenshots/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/tests/Browser/source/.gitignore b/tests/Browser/source/.gitignore new file mode 100644 index 0000000000..d6b7ef32c8 --- /dev/null +++ b/tests/Browser/source/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/tests/DuskTestCase.php b/tests/DuskTestCase.php new file mode 100644 index 0000000000..a01a6edca0 --- /dev/null +++ b/tests/DuskTestCase.php @@ -0,0 +1,74 @@ +addArguments(collect([ + $this->shouldStartMaximized() ? '--start-maximized' : '--window-size=1920,1080', + ])->unless($this->hasHeadlessDisabled(), function ($items) { + return $items->merge([ + '--disable-gpu', + '--headless', + ]); + })->all()); + + return RemoteWebDriver::create( + $_ENV['DUSK_DRIVER_URL'] ?? 'http://localhost:9515', + DesiredCapabilities::chrome()->setCapability( + ChromeOptions::CAPABILITY, + $options + ) + ); + } + + /** + * Determine whether the Dusk command has disabled headless mode. + * + * @return bool + */ + protected function hasHeadlessDisabled() + { + return isset($_SERVER['DUSK_HEADLESS_DISABLED']) || + isset($_ENV['DUSK_HEADLESS_DISABLED']); + } + + /** + * Determine if the browser window should start maximized. + * + * @return bool + */ + protected function shouldStartMaximized() + { + return isset($_SERVER['DUSK_START_MAXIMIZED']) || + isset($_ENV['DUSK_START_MAXIMIZED']); + } +} diff --git a/tests/Traits/DatabaseMigrations.php b/tests/Traits/DatabaseMigrations.php new file mode 100644 index 0000000000..7efa476d9c --- /dev/null +++ b/tests/Traits/DatabaseMigrations.php @@ -0,0 +1,25 @@ +artisan('migrate:fresh', $this->migrateFreshUsing()); + + $this->app[Kernel::class]->setArtisan(null); + } +}