mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-20 00:23:27 +00:00
test(browser): expand config coverage and shared helpers
Add BrowserTestHelpers and configuration/deploy browser tests for apps, databases, services, and Livewire toggles. Refactor existing browser suites onto shared seed/login helpers, soft-skip onboarding, and null broadcasting so host-side Pest runs avoid docker DNS.
This commit is contained in:
@@ -25,6 +25,8 @@
|
||||
<env name="QUEUE_CONNECTION" value="sync" force="true"/>
|
||||
<env name="SESSION_DRIVER" value="array" force="true"/>
|
||||
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
||||
<!-- Host-side Pest browser runs cannot resolve docker DNS (coolify-realtime). -->
|
||||
<env name="BROADCAST_DRIVER" value="null" force="true"/>
|
||||
<!-- The v5 bootstrap endpoint refuses to queue without a Flux URL; tests
|
||||
that exercise the unconfigured path blank it via Config::set. -->
|
||||
<env name="COOLIFY_COOLD_FLUX_URL" value="http://flux.testing:6443" force="true"/>
|
||||
|
||||
+21
-2
@@ -26,6 +26,7 @@ uses(TestCase::class)->in('Feature', 'v4/Feature', 'v4/Browser', 'v5/Browser');
|
||||
*/
|
||||
|
||||
require_once __DIR__.'/Support/V5TestHelpers.php';
|
||||
require_once __DIR__.'/Support/BrowserTestHelpers.php';
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@@ -41,15 +42,33 @@ beforeEach(function () {
|
||||
|
||||
// Flush the Server identity map cache to ensure tests get fresh data
|
||||
Server::flushIdentityMap();
|
||||
|
||||
// Browser Livewire actions often dispatch events; the Soketi host is not
|
||||
// resolvable from host-side Pest runs (docker DNS name coolify-realtime).
|
||||
config(['broadcasting.default' => 'null']);
|
||||
});
|
||||
|
||||
function loginAndSkipBoarding(string $email = 'test@example.com', string $password = 'password'): mixed
|
||||
{
|
||||
return visit('/login')
|
||||
$page = visit('/login')
|
||||
->fill('email', $email)
|
||||
->fill('password', $password)
|
||||
->click('Login')
|
||||
->click('Skip Setup');
|
||||
->wait(1.5);
|
||||
|
||||
// First-login root users land on onboarding; skip when the control exists.
|
||||
$page->script(<<<'JS'
|
||||
(() => {
|
||||
const candidates = Array.from(document.querySelectorAll('button, a, [role="button"]'));
|
||||
const skip = candidates.find((el) => (el.textContent || '').trim().toLowerCase() === 'skip setup');
|
||||
if (skip) {
|
||||
skip.click();
|
||||
}
|
||||
})()
|
||||
JS);
|
||||
$page->wait(1.5);
|
||||
|
||||
return $page;
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Shared Browser Test Helpers
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Helpers for Pest browser tests under tests/v4/Browser and tests/v5/Browser.
|
||||
| Loaded from tests/Pest.php.
|
||||
|
|
||||
*/
|
||||
|
||||
use App\Enums\ProxyStatus;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* InstanceSettings.id is not fillable; always forceCreate id 0 for browser tests.
|
||||
*/
|
||||
function seedBrowserInstanceSettings(array $attributes = []): InstanceSettings
|
||||
{
|
||||
return InstanceSettings::forceCreate(array_merge([
|
||||
'id' => 0,
|
||||
'is_sponsorship_popup_enabled' => false,
|
||||
'is_registration_enabled' => true,
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
/**
|
||||
* Root user (id 0) with known credentials for browser login.
|
||||
*/
|
||||
function createBrowserRootUser(
|
||||
string $email = 'test@example.com',
|
||||
string $password = 'password',
|
||||
string $name = 'Root User',
|
||||
): User {
|
||||
return User::forceCreate([
|
||||
'id' => 0,
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'password' => Hash::make($password),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Development-style OpenSSH private key used across browser fixtures.
|
||||
*/
|
||||
function browserTestPrivateKeyPem(): string
|
||||
{
|
||||
return <<<'KEY'
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
KEY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed instance settings, root user, SSH key, localhost server, destination,
|
||||
* project and default environment for resource browser tests.
|
||||
*
|
||||
* @return array{
|
||||
* user: User,
|
||||
* privateKey: PrivateKey,
|
||||
* server: Server,
|
||||
* destination: StandaloneDocker,
|
||||
* project: Project,
|
||||
* environment: Environment
|
||||
* }
|
||||
*/
|
||||
function seedBrowserResourceStack(array $overrides = []): array
|
||||
{
|
||||
seedBrowserInstanceSettings($overrides['instanceSettings'] ?? []);
|
||||
|
||||
$user = createBrowserRootUser(
|
||||
$overrides['email'] ?? 'test@example.com',
|
||||
$overrides['password'] ?? 'password',
|
||||
$overrides['name'] ?? 'Root User',
|
||||
);
|
||||
|
||||
$privateKey = PrivateKey::create([
|
||||
'id' => 1,
|
||||
'uuid' => $overrides['privateKeyUuid'] ?? 'ssh-test',
|
||||
'team_id' => 0,
|
||||
'name' => 'Test Key',
|
||||
'description' => 'Test SSH key',
|
||||
'private_key' => browserTestPrivateKeyPem(),
|
||||
]);
|
||||
|
||||
$server = Server::create([
|
||||
'id' => 0,
|
||||
'uuid' => $overrides['serverUuid'] ?? 'localhost',
|
||||
'name' => $overrides['serverName'] ?? 'localhost',
|
||||
'description' => $overrides['serverDescription'] ?? 'Test docker container in development',
|
||||
'ip' => $overrides['serverIp'] ?? 'coolify-testing-host',
|
||||
'team_id' => 0,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'proxy' => [
|
||||
'type' => ProxyTypes::TRAEFIK->value,
|
||||
'status' => ProxyStatus::EXITED->value,
|
||||
],
|
||||
]);
|
||||
|
||||
if ($server->settings) {
|
||||
$server->settings->is_reachable = true;
|
||||
$server->settings->is_usable = true;
|
||||
$server->settings->save();
|
||||
}
|
||||
|
||||
$destination = null;
|
||||
StandaloneDocker::withoutEvents(function () use ($server, &$destination, $overrides) {
|
||||
$destination = StandaloneDocker::firstOrCreate(
|
||||
['server_id' => $server->id, 'network' => $overrides['network'] ?? 'coolify'],
|
||||
[
|
||||
'uuid' => $overrides['destinationUuid'] ?? 'docker-destination-1',
|
||||
'name' => $overrides['destinationName'] ?? 'coolify',
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
$project = Project::create([
|
||||
'uuid' => $overrides['projectUuid'] ?? 'project-browser',
|
||||
'name' => $overrides['projectName'] ?? 'Browser Project',
|
||||
'description' => $overrides['projectDescription'] ?? 'Browser test project',
|
||||
'team_id' => 0,
|
||||
]);
|
||||
|
||||
$environment = $project->environments()->first();
|
||||
|
||||
Team::query()->whereKey(0)->update(['show_boarding' => false]);
|
||||
|
||||
return compact('user', 'privateKey', 'server', 'destination', 'project', 'environment');
|
||||
}
|
||||
|
||||
function createBrowserApplication(array $stack, array $attributes = []): Application
|
||||
{
|
||||
return Application::factory()->create(array_merge([
|
||||
'uuid' => $attributes['uuid'] ?? 'app-browser-'.Str::lower(Str::random(8)),
|
||||
'name' => $attributes['name'] ?? 'Browser App',
|
||||
'description' => $attributes['description'] ?? 'Browser test application',
|
||||
'git_repository' => $attributes['git_repository'] ?? 'https://github.com/coollabsio/coolify.git',
|
||||
'git_branch' => $attributes['git_branch'] ?? 'main',
|
||||
'build_pack' => $attributes['build_pack'] ?? 'nixpacks',
|
||||
'ports_exposes' => $attributes['ports_exposes'] ?? '3000',
|
||||
'environment_id' => $stack['environment']->id,
|
||||
'destination_id' => $stack['destination']->id,
|
||||
'destination_type' => $stack['destination']->getMorphClass(),
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
function createBrowserPostgresql(array $stack, array $attributes = []): StandalonePostgresql
|
||||
{
|
||||
return StandalonePostgresql::create(array_merge([
|
||||
'uuid' => $attributes['uuid'] ?? 'db-pg-'.Str::lower(Str::random(8)),
|
||||
'name' => $attributes['name'] ?? 'Browser Postgres',
|
||||
'description' => $attributes['description'] ?? 'Browser test database',
|
||||
'postgres_user' => $attributes['postgres_user'] ?? 'postgres',
|
||||
'postgres_password' => $attributes['postgres_password'] ?? 'postgres-password',
|
||||
'postgres_db' => $attributes['postgres_db'] ?? 'postgres',
|
||||
'image' => $attributes['image'] ?? 'postgres:15-alpine',
|
||||
'status' => $attributes['status'] ?? 'exited',
|
||||
'environment_id' => $stack['environment']->id,
|
||||
'destination_id' => $stack['destination']->id,
|
||||
'destination_type' => $stack['destination']->getMorphClass(),
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
function createBrowserRedis(array $stack, array $attributes = []): StandaloneRedis
|
||||
{
|
||||
// redis_password was moved to environment variables (no column on standalone_redis).
|
||||
return StandaloneRedis::forceCreate(array_merge([
|
||||
'uuid' => $attributes['uuid'] ?? 'db-redis-'.Str::lower(Str::random(8)),
|
||||
'name' => $attributes['name'] ?? 'Browser Redis',
|
||||
'description' => $attributes['description'] ?? 'Browser test redis',
|
||||
'image' => $attributes['image'] ?? 'redis:7-alpine',
|
||||
'status' => $attributes['status'] ?? 'exited',
|
||||
'environment_id' => $stack['environment']->id,
|
||||
'destination_id' => $stack['destination']->id,
|
||||
'destination_type' => $stack['destination']->getMorphClass(),
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{service: Service, serviceApplication: ServiceApplication}
|
||||
*/
|
||||
function createBrowserService(array $stack, array $attributes = []): array
|
||||
{
|
||||
$service = Service::factory()->create(array_merge([
|
||||
'uuid' => $attributes['uuid'] ?? 'svc-'.Str::lower(Str::random(8)),
|
||||
'name' => $attributes['name'] ?? 'Browser Service',
|
||||
'description' => $attributes['description'] ?? 'Browser test compose service',
|
||||
'environment_id' => $stack['environment']->id,
|
||||
'server_id' => $stack['server']->id,
|
||||
'destination_id' => $stack['destination']->id,
|
||||
'destination_type' => $stack['destination']->getMorphClass(),
|
||||
'docker_compose_raw' => $attributes['docker_compose_raw'] ?? "services:\n web:\n image: nginx:alpine\n ports:\n - '80'\n",
|
||||
], $attributes));
|
||||
|
||||
$serviceApplication = ServiceApplication::forceCreate([
|
||||
'uuid' => $attributes['serviceApplicationUuid'] ?? (string) Str::uuid(),
|
||||
'name' => $attributes['serviceApplicationName'] ?? 'web',
|
||||
'service_id' => $service->id,
|
||||
'image' => $attributes['image'] ?? 'nginx:alpine',
|
||||
]);
|
||||
|
||||
return compact('service', 'serviceApplication');
|
||||
}
|
||||
|
||||
function applicationConfigurationUrl(Project $project, $environment, Application $application): string
|
||||
{
|
||||
return "/project/{$project->uuid}/environment/{$environment->uuid}/application/{$application->uuid}";
|
||||
}
|
||||
|
||||
function databaseConfigurationUrl(Project $project, $environment, StandalonePostgresql|StandaloneRedis $database): string
|
||||
{
|
||||
return "/project/{$project->uuid}/environment/{$environment->uuid}/database/{$database->uuid}";
|
||||
}
|
||||
|
||||
function serviceConfigurationUrl(Project $project, $environment, Service $service): string
|
||||
{
|
||||
return "/project/{$project->uuid}/environment/{$environment->uuid}/service/{$service->uuid}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose an option from a Coolify Alpine listbox (`x-forms.listbox`).
|
||||
*
|
||||
* Opens the trigger `#{$id}-trigger`, then clicks the option whose label matches
|
||||
* within that listbox only (avoids matching same labels on other listboxes, e.g.
|
||||
* Build strategy "Static" vs Site type "Static").
|
||||
*
|
||||
* Uses DOM evaluation for the option click so labels with CSS-special characters
|
||||
* like parentheses (e.g. "SPA (single-page application)", "allow (insecure)") work.
|
||||
*/
|
||||
function selectListboxOption(mixed $page, string $id, string $optionLabel, float $waitSeconds = 1.5): void
|
||||
{
|
||||
$escapedId = json_encode($id, JSON_THROW_ON_ERROR);
|
||||
$escapedLabel = json_encode($optionLabel, JSON_THROW_ON_ERROR);
|
||||
|
||||
$opened = $page->script(<<<JS
|
||||
(() => {
|
||||
const id = {$escapedId};
|
||||
const trigger = document.querySelector('#' + id + '-trigger');
|
||||
if (!trigger) {
|
||||
return 'missing-trigger';
|
||||
}
|
||||
trigger.click();
|
||||
return 'opened';
|
||||
})()
|
||||
JS);
|
||||
|
||||
if ($opened !== 'opened') {
|
||||
throw new RuntimeException("Listbox trigger #{$id}-trigger not found.");
|
||||
}
|
||||
|
||||
$page->wait(0.4);
|
||||
|
||||
$clicked = $page->script(<<<JS
|
||||
(() => {
|
||||
const id = {$escapedId};
|
||||
const label = {$escapedLabel};
|
||||
const trigger = document.querySelector('#' + id + '-trigger');
|
||||
if (!trigger) {
|
||||
return 'missing-trigger';
|
||||
}
|
||||
|
||||
// Scope to the listbox root that owns this trigger.
|
||||
const root = trigger.closest('.relative') || trigger.parentElement;
|
||||
const options = Array.from((root || document).querySelectorAll('[role="option"]'));
|
||||
const match = options.find((el) => {
|
||||
if (el.offsetParent === null && getComputedStyle(el).display === 'none') {
|
||||
return false;
|
||||
}
|
||||
const text = (el.textContent || '').replace(/\\s+/g, ' ').trim();
|
||||
return text === label;
|
||||
});
|
||||
|
||||
if (!match) {
|
||||
return 'missing-option:' + options.map((el) => (el.textContent || '').trim()).join('|');
|
||||
}
|
||||
|
||||
match.click();
|
||||
return 'clicked';
|
||||
})()
|
||||
JS);
|
||||
|
||||
if ($clicked !== 'clicked') {
|
||||
throw new RuntimeException("Listbox option [{$optionLabel}] not selected for #{$id}: {$clicked}");
|
||||
}
|
||||
|
||||
$page->wait($waitSeconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the primary Livewire settings form on the page.
|
||||
*/
|
||||
function submitLivewireForm(mixed $page, string $submitAction = 'submit'): void
|
||||
{
|
||||
$escaped = addcslashes($submitAction, '"\\');
|
||||
$page->script(<<<JS
|
||||
(() => {
|
||||
const forms = Array.from(document.querySelectorAll('form'));
|
||||
const form = forms.find((candidate) => {
|
||||
const attrs = candidate.getAttributeNames();
|
||||
return attrs.some((name) => name.startsWith('wire:submit') && candidate.getAttribute(name) === "{$escaped}");
|
||||
}) || forms.find((candidate) => candidate.getAttributeNames().some((name) => name.startsWith('wire:submit')))
|
||||
|| document.querySelector('form.application-settings-form');
|
||||
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prefer Livewire's submit hook when available; fall back to native submit.
|
||||
if (typeof form.requestSubmit === 'function') {
|
||||
form.requestSubmit();
|
||||
} else {
|
||||
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||
}
|
||||
})()
|
||||
JS);
|
||||
$page->wait(2);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->stack = seedBrowserResourceStack();
|
||||
$this->application = createBrowserApplication($this->stack, [
|
||||
'uuid' => 'app-browser-config',
|
||||
'name' => 'Config App',
|
||||
'ports_exposes' => '3000',
|
||||
'custom_docker_run_options' => null,
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows application configuration sections and navigation', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$url = applicationConfigurationUrl(
|
||||
$this->stack['project'],
|
||||
$this->stack['environment'],
|
||||
$this->application
|
||||
);
|
||||
|
||||
$page = visit($url);
|
||||
|
||||
$page->assertSee('Config App')
|
||||
->assertSee('General')
|
||||
->assertSee('Environment Variables')
|
||||
->assertSee('Danger Zone')
|
||||
->assertSee('Application details')
|
||||
->assertSee('Build pipeline')
|
||||
->screenshot(filename: 'application-configuration-overview');
|
||||
});
|
||||
|
||||
it('saves application name description and ports from the general form', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$updatedName = 'App UI '.(string) new Cuid2;
|
||||
$url = applicationConfigurationUrl(
|
||||
$this->stack['project'],
|
||||
$this->stack['environment'],
|
||||
$this->application
|
||||
);
|
||||
|
||||
$page = visit($url);
|
||||
$page->assertSee('General')
|
||||
->fill('name', $updatedName)
|
||||
->fill('description', 'Updated via browser test')
|
||||
->fill('portsExposes', '8080')
|
||||
->screenshot(filename: 'application-general-before-save');
|
||||
|
||||
submitLivewireForm($page);
|
||||
|
||||
$page->assertValue('name', $updatedName)
|
||||
->screenshot(filename: 'application-general-after-save');
|
||||
|
||||
$this->application->refresh();
|
||||
expect($this->application->name)->toBe($updatedName)
|
||||
->and($this->application->description)->toBe('Updated via browser test')
|
||||
->and($this->application->ports_exposes)->toBe('8080');
|
||||
|
||||
$reloaded = visit($url);
|
||||
$reloaded->assertValue('name', $updatedName)
|
||||
->assertValue('description', 'Updated via browser test')
|
||||
->assertValue('portsExposes', '8080')
|
||||
->screenshot(filename: 'application-general-reloaded');
|
||||
});
|
||||
|
||||
it('saves custom docker run options from the UI', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$url = applicationConfigurationUrl(
|
||||
$this->stack['project'],
|
||||
$this->stack['environment'],
|
||||
$this->application
|
||||
);
|
||||
$options = '--hostname=browser-app --cap-add=SYS_ADMIN';
|
||||
|
||||
$page = visit($url);
|
||||
$page->fill('customDockerRunOptions', $options);
|
||||
submitLivewireForm($page);
|
||||
|
||||
$this->application->refresh();
|
||||
expect($this->application->custom_docker_run_options)->toBe($options);
|
||||
|
||||
$page->assertValue('customDockerRunOptions', $options)
|
||||
->screenshot(filename: 'application-docker-run-options');
|
||||
});
|
||||
|
||||
it('opens environment variables page for the application', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$base = applicationConfigurationUrl(
|
||||
$this->stack['project'],
|
||||
$this->stack['environment'],
|
||||
$this->application
|
||||
);
|
||||
|
||||
$page = visit("{$base}/environment-variables");
|
||||
|
||||
$page->assertSee('Environment Variables')
|
||||
->assertSee('Config App')
|
||||
->screenshot(filename: 'application-environment-variables');
|
||||
});
|
||||
|
||||
it('opens advanced and healthcheck configuration pages', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$base = applicationConfigurationUrl(
|
||||
$this->stack['project'],
|
||||
$this->stack['environment'],
|
||||
$this->application
|
||||
);
|
||||
|
||||
visit("{$base}/advanced")
|
||||
->assertSee('Config App')
|
||||
->screenshot(filename: 'application-advanced');
|
||||
|
||||
visit("{$base}/healthcheck")
|
||||
->assertSee('Config App')
|
||||
->screenshot(filename: 'application-healthcheck');
|
||||
});
|
||||
|
||||
it('lists the application on the environment resources page', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$project = $this->stack['project'];
|
||||
$environment = $this->stack['environment'];
|
||||
|
||||
$page = visit("/project/{$project->uuid}/environment/{$environment->uuid}");
|
||||
|
||||
$page->assertSee('Config App')
|
||||
->screenshot(filename: 'environment-lists-application');
|
||||
});
|
||||
|
||||
it('shows danger zone for application deletion', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$base = applicationConfigurationUrl(
|
||||
$this->stack['project'],
|
||||
$this->stack['environment'],
|
||||
$this->application
|
||||
);
|
||||
|
||||
$page = visit("{$base}/danger");
|
||||
|
||||
$page->assertSee('Danger')
|
||||
->assertSee('Config App')
|
||||
->screenshot(filename: 'application-danger-zone');
|
||||
});
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
use App\Enums\ProxyStatus;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
@@ -13,46 +12,13 @@ use Illuminate\Support\Facades\Hash;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::create(['id' => 0, 'is_sponsorship_popup_enabled' => false]);
|
||||
|
||||
// Create root/owner user
|
||||
$this->user = User::factory()->create([
|
||||
'id' => 0,
|
||||
'name' => 'Root User',
|
||||
'email' => 'test@example.com',
|
||||
'password' => Hash::make('password'),
|
||||
]);
|
||||
|
||||
// Create SSH key for the root user's team
|
||||
PrivateKey::create([
|
||||
'id' => 1,
|
||||
'uuid' => 'ssh-test',
|
||||
'team_id' => 0,
|
||||
'name' => 'Test Key',
|
||||
'description' => 'Test SSH key',
|
||||
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----',
|
||||
]);
|
||||
|
||||
// Create servers for testing
|
||||
Server::create([
|
||||
'id' => 0,
|
||||
'uuid' => 'localhost',
|
||||
'name' => 'localhost',
|
||||
'description' => 'Test docker container in development',
|
||||
'ip' => 'coolify-testing-host',
|
||||
'team_id' => 0,
|
||||
'private_key_id' => 1,
|
||||
'proxy' => [
|
||||
'type' => ProxyTypes::TRAEFIK->value,
|
||||
'status' => ProxyStatus::EXITED->value,
|
||||
],
|
||||
$this->stack = seedBrowserResourceStack([
|
||||
'projectUuid' => 'project-1',
|
||||
'projectName' => 'My first project',
|
||||
'projectDescription' => 'This is a test project',
|
||||
'serverDescription' => 'Test docker container in development',
|
||||
]);
|
||||
$this->user = $this->stack['user'];
|
||||
|
||||
Server::create([
|
||||
'uuid' => 'production-1',
|
||||
@@ -67,14 +33,6 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
],
|
||||
]);
|
||||
|
||||
// Create projects for testing
|
||||
Project::create([
|
||||
'uuid' => 'project-1',
|
||||
'name' => 'My first project',
|
||||
'description' => 'This is a test project',
|
||||
'team_id' => 0,
|
||||
]);
|
||||
|
||||
Project::create([
|
||||
'uuid' => 'project-2',
|
||||
'name' => 'Production API',
|
||||
@@ -94,6 +52,9 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
$personalTeam->delete();
|
||||
// Attach member to root team (id=0) with 'member' role
|
||||
$this->member->teams()->attach(0, ['role' => 'member']);
|
||||
|
||||
// Skip onboarding UI for both owner and member sessions.
|
||||
Team::query()->whereKey(0)->update(['show_boarding' => false]);
|
||||
});
|
||||
|
||||
function loginAsMember(): mixed
|
||||
@@ -101,7 +62,8 @@ function loginAsMember(): mixed
|
||||
return visit('/login')
|
||||
->fill('email', 'member@example.com')
|
||||
->fill('password', 'password')
|
||||
->click('Login');
|
||||
->click('Login')
|
||||
->wait(1);
|
||||
}
|
||||
|
||||
it('redirects unauthenticated users to login', function () {
|
||||
@@ -115,7 +77,8 @@ it('shows dashboard after successful login and onboarding skip', function () {
|
||||
$page = loginAndSkipBoarding();
|
||||
|
||||
$page->assertSee('Dashboard')
|
||||
->assertSee('Your self-hosted infrastructure')
|
||||
->assertSee('Projects')
|
||||
->assertSee('Servers')
|
||||
->screenshot();
|
||||
});
|
||||
|
||||
@@ -147,7 +110,9 @@ it('allows authenticated users to access team settings', function () {
|
||||
$page = visit('/team');
|
||||
|
||||
$page->assertSee('General')
|
||||
->assertSee('Manage the general settings of this team')
|
||||
->assertSee('Name')
|
||||
->assertSee('MCP server')
|
||||
->assertSee('Root Team')
|
||||
->screenshot();
|
||||
});
|
||||
|
||||
@@ -156,8 +121,8 @@ it('shows danger zone to team owner', function () {
|
||||
|
||||
$page = visit('/team');
|
||||
|
||||
$page->assertSee('Danger Zone')
|
||||
->assertSee('Delete Team')
|
||||
$page->assertSee('Danger zone')
|
||||
->assertSee('Destructive actions for this team.')
|
||||
->screenshot();
|
||||
});
|
||||
|
||||
@@ -248,8 +213,8 @@ it('member does not see danger zone on team settings', function () {
|
||||
$page = visit('/team');
|
||||
|
||||
$page->assertSee('General')
|
||||
->assertDontSee('Danger Zone')
|
||||
->assertDontSee('Delete Team')
|
||||
->assertDontSee('Danger zone')
|
||||
->assertDontSee('Destructive actions for this team.')
|
||||
->screenshot();
|
||||
});
|
||||
|
||||
@@ -315,8 +280,9 @@ it('member does not see add environment button on project page', function () {
|
||||
$project = Project::where('uuid', 'project-1')->first();
|
||||
$page = visit("/project/{$project->uuid}");
|
||||
|
||||
$page->assertSee('Environments')
|
||||
->assertDontSee('+ Add')
|
||||
$page->assertSee('My first project')
|
||||
->assertSee('in this project')
|
||||
->assertDontSee('New environment')
|
||||
->screenshot();
|
||||
});
|
||||
|
||||
@@ -326,7 +292,7 @@ it('member does not see environment settings link on project page', function ()
|
||||
$project = Project::where('uuid', 'project-1')->first();
|
||||
$page = visit("/project/{$project->uuid}");
|
||||
|
||||
$page->assertSee('Environments')
|
||||
$page->assertSee('My first project')
|
||||
->assertDontSee('Settings')
|
||||
->screenshot();
|
||||
});
|
||||
@@ -337,8 +303,8 @@ it('owner sees add environment and settings on project page', function () {
|
||||
$project = Project::where('uuid', 'project-1')->first();
|
||||
$page = visit("/project/{$project->uuid}");
|
||||
|
||||
$page->assertSee('Environments')
|
||||
->assertSee('+ Add')
|
||||
$page->assertSee('My first project')
|
||||
->assertSee('New environment')
|
||||
->assertSee('Settings')
|
||||
->screenshot();
|
||||
});
|
||||
@@ -350,7 +316,8 @@ it('member does not see add resource link on dashboard project cards', function
|
||||
|
||||
$page->assertSee('Projects')
|
||||
->assertSee('My first project')
|
||||
->assertDontSee('+ Add Resource')
|
||||
->assertDontSee('Add resource to My first project')
|
||||
->assertSourceMissing('title="Add resource"')
|
||||
->screenshot();
|
||||
});
|
||||
|
||||
@@ -361,17 +328,16 @@ it('owner sees add resource link on dashboard project cards', function () {
|
||||
|
||||
$page->assertSee('Projects')
|
||||
->assertSee('My first project')
|
||||
->assertSee('+ Add Resource')
|
||||
->assertSourceHas('title="Add resource"')
|
||||
->screenshot();
|
||||
});
|
||||
|
||||
it('member does not see project settings link on dashboard', function () {
|
||||
$page = loginAsMember();
|
||||
|
||||
// The "Settings" link is inside the project card on the dashboard
|
||||
// Member should not see it due to @can('update', $project)
|
||||
// Project settings control is an icon link with this aria-label for owners only.
|
||||
$page->assertSee('My first project')
|
||||
->assertDontSee('Settings')
|
||||
->assertSourceMissing('Open settings for My first project')
|
||||
->screenshot();
|
||||
});
|
||||
|
||||
@@ -383,7 +349,7 @@ it('member does not see invite form on team members page', function () {
|
||||
$page = visit('/team/members');
|
||||
|
||||
$page->assertSee('Members')
|
||||
->assertDontSee('Invite New Member')
|
||||
->assertDontSee('Invite a member')
|
||||
->screenshot();
|
||||
});
|
||||
|
||||
@@ -393,7 +359,7 @@ it('owner sees invite form on team members page', function () {
|
||||
$page = visit('/team/members');
|
||||
|
||||
$page->assertSee('Members')
|
||||
->assertSee('Invite New Member')
|
||||
->assertSee('Invite a member')
|
||||
->screenshot();
|
||||
});
|
||||
|
||||
@@ -441,7 +407,8 @@ it('member does not see delete project button on project page', function () {
|
||||
$project = Project::where('uuid', 'project-1')->first();
|
||||
$page = visit("/project/{$project->uuid}");
|
||||
|
||||
$page->assertSee('Environments')
|
||||
$page->assertSee('My first project')
|
||||
->assertDontSee('Delete Project')
|
||||
->assertDontSee('Delete project')
|
||||
->screenshot();
|
||||
});
|
||||
|
||||
@@ -2,53 +2,19 @@
|
||||
|
||||
use App\Enums\ProxyStatus;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::create(['id' => 0, 'is_sponsorship_popup_enabled' => false]);
|
||||
|
||||
$this->user = User::factory()->create([
|
||||
'id' => 0,
|
||||
'name' => 'Root User',
|
||||
'email' => 'test@example.com',
|
||||
'password' => Hash::make('password'),
|
||||
]);
|
||||
|
||||
PrivateKey::create([
|
||||
'id' => 1,
|
||||
'uuid' => 'ssh-test',
|
||||
'team_id' => 0,
|
||||
'name' => 'Test Key',
|
||||
'description' => 'Test SSH key',
|
||||
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----',
|
||||
]);
|
||||
|
||||
Server::create([
|
||||
'id' => 0,
|
||||
'uuid' => 'localhost',
|
||||
'name' => 'localhost',
|
||||
'description' => 'This is a test docker container in development mode',
|
||||
'ip' => 'coolify-testing-host',
|
||||
'team_id' => 0,
|
||||
'private_key_id' => 1,
|
||||
'proxy' => [
|
||||
'type' => ProxyTypes::TRAEFIK->value,
|
||||
'status' => ProxyStatus::EXITED->value,
|
||||
],
|
||||
$this->stack = seedBrowserResourceStack([
|
||||
'projectUuid' => 'project-1',
|
||||
'projectName' => 'My first project',
|
||||
'projectDescription' => 'This is a test project in development',
|
||||
'serverDescription' => 'This is a test docker container in development mode',
|
||||
]);
|
||||
|
||||
Server::create([
|
||||
@@ -77,13 +43,6 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
],
|
||||
]);
|
||||
|
||||
Project::create([
|
||||
'uuid' => 'project-1',
|
||||
'name' => 'My first project',
|
||||
'description' => 'This is a test project in development',
|
||||
'team_id' => 0,
|
||||
]);
|
||||
|
||||
Project::create([
|
||||
'uuid' => 'project-2',
|
||||
'name' => 'Production API',
|
||||
@@ -107,23 +66,28 @@ it('redirects to login when not authenticated', function () {
|
||||
});
|
||||
|
||||
it('shows onboarding after first login', function () {
|
||||
// seedBrowserResourceStack disables boarding for most browser flows; re-enable for this case.
|
||||
Team::query()->whereKey(0)->update(['show_boarding' => true]);
|
||||
|
||||
$page = visit('/login');
|
||||
|
||||
$page->fill('email', 'test@example.com')
|
||||
->fill('password', 'password')
|
||||
->click('Login')
|
||||
->wait(1.5)
|
||||
->assertSee('Welcome to Coolify')
|
||||
->assertSee("Let's go!")
|
||||
->assertSee('Skip Setup')
|
||||
->screenshot();
|
||||
->assertSee('Continue')
|
||||
->assertSee('Skip setup')
|
||||
->screenshot(filename: 'dashboard-onboarding-after-login');
|
||||
});
|
||||
|
||||
it('shows dashboard after skipping onboarding', function () {
|
||||
$page = loginAndSkipBoarding();
|
||||
|
||||
$page->assertSee('Dashboard')
|
||||
->assertSee('Your self-hosted infrastructure.')
|
||||
->screenshot();
|
||||
->assertSee('Projects')
|
||||
->assertSee('Servers')
|
||||
->screenshot(filename: 'dashboard-after-onboarding');
|
||||
});
|
||||
|
||||
it('shows all projects on dashboard', function () {
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->stack = seedBrowserResourceStack();
|
||||
$this->postgres = createBrowserPostgresql($this->stack, [
|
||||
'uuid' => 'db-browser-pg',
|
||||
'name' => 'Config Postgres',
|
||||
'description' => 'Initial postgres description',
|
||||
]);
|
||||
$this->redis = createBrowserRedis($this->stack, [
|
||||
'uuid' => 'db-browser-redis',
|
||||
'name' => 'Config Redis',
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows postgres configuration sections', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$url = databaseConfigurationUrl(
|
||||
$this->stack['project'],
|
||||
$this->stack['environment'],
|
||||
$this->postgres
|
||||
);
|
||||
|
||||
$page = visit($url);
|
||||
|
||||
$page->assertSee('Config Postgres')
|
||||
->assertSee('General')
|
||||
->assertSee('Environment Variables')
|
||||
->assertSee('Persistent Storage')
|
||||
->assertSee('Danger Zone')
|
||||
->assertSee('Username')
|
||||
->assertSee('Initial database')
|
||||
->screenshot(filename: 'database-postgres-configuration');
|
||||
});
|
||||
|
||||
it('saves postgres name description and credentials fields', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$updatedName = 'Postgres UI '.(string) new Cuid2;
|
||||
$url = databaseConfigurationUrl(
|
||||
$this->stack['project'],
|
||||
$this->stack['environment'],
|
||||
$this->postgres
|
||||
);
|
||||
|
||||
$page = visit($url);
|
||||
$page->fill('name', $updatedName)
|
||||
->fill('description', 'Updated postgres description')
|
||||
->fill('postgresDb', 'coolify_browser')
|
||||
->screenshot(filename: 'database-postgres-before-save');
|
||||
|
||||
submitLivewireForm($page);
|
||||
|
||||
$this->postgres->refresh();
|
||||
expect($this->postgres->name)->toBe($updatedName)
|
||||
->and($this->postgres->description)->toBe('Updated postgres description')
|
||||
->and($this->postgres->postgres_db)->toBe('coolify_browser');
|
||||
|
||||
visit($url)
|
||||
->assertValue('name', $updatedName)
|
||||
->assertValue('postgresDb', 'coolify_browser')
|
||||
->screenshot(filename: 'database-postgres-after-reload');
|
||||
});
|
||||
|
||||
it('shows ssl controls on postgres configuration', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$url = databaseConfigurationUrl(
|
||||
$this->stack['project'],
|
||||
$this->stack['environment'],
|
||||
$this->postgres
|
||||
);
|
||||
|
||||
$page = visit($url);
|
||||
// SSL toggle is present; mode selector appears after enabling SSL.
|
||||
$page->assertSee('General')
|
||||
->assertSee('Config Postgres')
|
||||
->screenshot(filename: 'database-postgres-ssl-controls');
|
||||
|
||||
$page->script(<<<'JS'
|
||||
(() => {
|
||||
const toggle = document.querySelector('[id^="enableSsl"]');
|
||||
if (toggle) {
|
||||
toggle.click();
|
||||
}
|
||||
})()
|
||||
JS);
|
||||
$page->wait(2);
|
||||
|
||||
$this->postgres->refresh();
|
||||
if ($this->postgres->enable_ssl) {
|
||||
$page->assertSee('SSL Mode');
|
||||
}
|
||||
|
||||
$page->screenshot(filename: 'database-postgres-ssl-after-toggle');
|
||||
});
|
||||
|
||||
it('shows redis configuration page', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$url = databaseConfigurationUrl(
|
||||
$this->stack['project'],
|
||||
$this->stack['environment'],
|
||||
$this->redis
|
||||
);
|
||||
|
||||
$page = visit($url);
|
||||
|
||||
$page->assertSee('Config Redis')
|
||||
->assertSee('General')
|
||||
->screenshot(filename: 'database-redis-configuration');
|
||||
});
|
||||
|
||||
it('lists databases on the environment resources page', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$project = $this->stack['project'];
|
||||
$environment = $this->stack['environment'];
|
||||
|
||||
$page = visit("/project/{$project->uuid}/environment/{$environment->uuid}");
|
||||
|
||||
$page->assertSee('Config Postgres')
|
||||
->assertSee('Config Redis')
|
||||
->screenshot(filename: 'environment-lists-databases');
|
||||
});
|
||||
|
||||
it('opens database environment variables and backups pages', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$base = databaseConfigurationUrl(
|
||||
$this->stack['project'],
|
||||
$this->stack['environment'],
|
||||
$this->postgres
|
||||
);
|
||||
|
||||
visit("{$base}/environment-variables")
|
||||
->assertSee('Environment Variables')
|
||||
->screenshot(filename: 'database-environment-variables');
|
||||
|
||||
visit("{$base}/backups")
|
||||
->assertSee('Config Postgres')
|
||||
->screenshot(filename: 'database-backups');
|
||||
});
|
||||
|
||||
it('shows database danger zone', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$base = databaseConfigurationUrl(
|
||||
$this->stack['project'],
|
||||
$this->stack['environment'],
|
||||
$this->postgres
|
||||
);
|
||||
|
||||
visit("{$base}/danger")
|
||||
->assertSee('Danger')
|
||||
->assertSee('Config Postgres')
|
||||
->screenshot(filename: 'database-danger-zone');
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Browser + domain assertions that configuration changes made in the UI
|
||||
* are persisted and reflected in generated Docker/Coolify deployment config.
|
||||
*
|
||||
* Full remote deploy against Docker is out of scope for pure browser tests;
|
||||
* these tests prove the deploy pipeline inputs update correctly after UI saves.
|
||||
*/
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->stack = seedBrowserResourceStack();
|
||||
$this->application = createBrowserApplication($this->stack, [
|
||||
'uuid' => 'app-browser-deploy-config',
|
||||
'name' => 'Deploy Config App',
|
||||
'build_pack' => 'nixpacks',
|
||||
'ports_exposes' => '3000',
|
||||
]);
|
||||
$this->postgres = createBrowserPostgresql($this->stack, [
|
||||
'uuid' => 'db-browser-deploy-config',
|
||||
'name' => 'Deploy Config Postgres',
|
||||
'custom_docker_run_options' => null,
|
||||
]);
|
||||
});
|
||||
|
||||
it('reflects application UI docker run options in compose conversion used by deploy', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$hostname = 'deploy-cfg-'.Str::lower(Str::random(8));
|
||||
$options = "--hostname={$hostname} --cap-add=SYS_ADMIN --shm-size=128m";
|
||||
$url = applicationConfigurationUrl(
|
||||
$this->stack['project'],
|
||||
$this->stack['environment'],
|
||||
$this->application
|
||||
);
|
||||
|
||||
$page = visit($url);
|
||||
$page->fill('name', 'Deploy Config App Saved')
|
||||
->fill('portsExposes', '4000')
|
||||
->fill('customDockerRunOptions', $options)
|
||||
->screenshot(filename: 'deploy-config-app-before-save');
|
||||
|
||||
submitLivewireForm($page);
|
||||
|
||||
$this->application->refresh();
|
||||
|
||||
expect($this->application->name)->toBe('Deploy Config App Saved')
|
||||
->and($this->application->ports_exposes)->toBe('4000')
|
||||
->and($this->application->custom_docker_run_options)->toBe($options);
|
||||
|
||||
$composeOptions = convertDockerRunToCompose($this->application->custom_docker_run_options);
|
||||
|
||||
expect(data_get($composeOptions, 'hostname'))->toBe($hostname)
|
||||
->and(data_get($composeOptions, 'cap_add'))->toContain('SYS_ADMIN')
|
||||
->and(data_get($composeOptions, 'shm_size'))->toBe('128m');
|
||||
|
||||
// Coolify configuration export includes ports; docker run options feed Start* compose merge.
|
||||
$configuration = $this->application->getConfigurationAsArray();
|
||||
expect($configuration)->toBeArray()
|
||||
->and(data_get($configuration, 'domains.ports_exposes'))->toBe('4000');
|
||||
|
||||
visit($url)
|
||||
->assertValue('portsExposes', '4000')
|
||||
->assertValue('customDockerRunOptions', $options)
|
||||
->screenshot(filename: 'deploy-config-app-after-reload');
|
||||
});
|
||||
|
||||
it('reflects database UI custom docker run options in compose conversion', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$hostname = 'pg-deploy-'.Str::lower(Str::random(8));
|
||||
$options = "--hostname={$hostname} --shm-size=256m";
|
||||
$url = databaseConfigurationUrl(
|
||||
$this->stack['project'],
|
||||
$this->stack['environment'],
|
||||
$this->postgres
|
||||
);
|
||||
|
||||
$page = visit($url);
|
||||
$page->fill('customDockerRunOptions', $options);
|
||||
submitLivewireForm($page);
|
||||
|
||||
$this->postgres->refresh();
|
||||
expect($this->postgres->custom_docker_run_options)->toBe($options);
|
||||
|
||||
$composeOptions = convertDockerRunToCompose($this->postgres->custom_docker_run_options);
|
||||
expect(data_get($composeOptions, 'hostname'))->toBe($hostname)
|
||||
->and(data_get($composeOptions, 'shm_size'))->toBe('256m');
|
||||
|
||||
visit($url)
|
||||
->assertValue('customDockerRunOptions', $options)
|
||||
->screenshot(filename: 'deploy-config-database-docker-options');
|
||||
});
|
||||
|
||||
it('updates deploy-relevant fields used for configuration change detection', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$url = applicationConfigurationUrl(
|
||||
$this->stack['project'],
|
||||
$this->stack['environment'],
|
||||
$this->application
|
||||
);
|
||||
|
||||
$beforePorts = $this->application->ports_exposes;
|
||||
$beforeOptions = $this->application->custom_docker_run_options;
|
||||
|
||||
$page = visit($url);
|
||||
$page->fill('portsExposes', '9090')
|
||||
->fill('customDockerRunOptions', '--hostname=hash-check-app');
|
||||
submitLivewireForm($page);
|
||||
|
||||
$this->application->refresh();
|
||||
|
||||
expect($this->application->ports_exposes)->toBe('9090')
|
||||
->and($this->application->custom_docker_run_options)->toBe('--hostname=hash-check-app')
|
||||
->and($this->application->ports_exposes)->not->toBe($beforePorts)
|
||||
->and($this->application->custom_docker_run_options)->not->toBe($beforeOptions);
|
||||
|
||||
$composeOptions = convertDockerRunToCompose($this->application->custom_docker_run_options);
|
||||
expect(data_get($composeOptions, 'hostname'))->toBe('hash-check-app');
|
||||
|
||||
$page->screenshot(filename: 'deploy-config-hash-inputs');
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Browser coverage for Livewire toggle edge cases that previously flaked:
|
||||
* - Application site type: Dynamic → Static → SPA (nginx config generation)
|
||||
* - PostgreSQL SSL enable/disable and every ssl_mode option
|
||||
*/
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->stack = seedBrowserResourceStack();
|
||||
$this->application = createBrowserApplication($this->stack, [
|
||||
'uuid' => 'app-toggle-edge',
|
||||
'name' => 'Toggle Edge App',
|
||||
'build_pack' => 'nixpacks',
|
||||
'ports_exposes' => '3000',
|
||||
]);
|
||||
$this->postgres = createBrowserPostgresql($this->stack, [
|
||||
'uuid' => 'db-toggle-ssl',
|
||||
'name' => 'Toggle SSL Postgres',
|
||||
// SSL controls are only editable while status contains "exited".
|
||||
'status' => 'exited',
|
||||
'enable_ssl' => false,
|
||||
'ssl_mode' => 'prefer',
|
||||
]);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Application: static / SPA / nginx
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('shows site type control for nixpacks applications', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$url = applicationConfigurationUrl(
|
||||
$this->stack['project'],
|
||||
$this->stack['environment'],
|
||||
$this->application
|
||||
);
|
||||
|
||||
$page = visit($url);
|
||||
|
||||
$page->assertSee('Site type')
|
||||
->assertSee('Dynamic')
|
||||
->assertDontSee('Custom Nginx configuration')
|
||||
->screenshot(filename: 'toggle-site-type-default-dynamic');
|
||||
});
|
||||
|
||||
it('enables static site and reveals custom nginx configuration', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$url = applicationConfigurationUrl(
|
||||
$this->stack['project'],
|
||||
$this->stack['environment'],
|
||||
$this->application
|
||||
);
|
||||
|
||||
$page = visit($url);
|
||||
selectListboxOption($page, 'siteType', 'Static', 2);
|
||||
|
||||
$page->assertSee('Custom Nginx configuration')
|
||||
->assertSee('Web server')
|
||||
->assertSee('nginx:alpine')
|
||||
->screenshot(filename: 'toggle-site-type-static');
|
||||
|
||||
$this->application->refresh();
|
||||
expect($this->application->settings->is_static)->toBeTrue()
|
||||
->and($this->application->settings->is_spa)->toBeFalse();
|
||||
|
||||
// Reload must keep static UI state.
|
||||
visit($url)
|
||||
->assertSee('Custom Nginx configuration')
|
||||
->assertSee('Static')
|
||||
->screenshot(filename: 'toggle-site-type-static-reloaded');
|
||||
});
|
||||
|
||||
it('switches to spa and generates spa nginx try_files config', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$url = applicationConfigurationUrl(
|
||||
$this->stack['project'],
|
||||
$this->stack['environment'],
|
||||
$this->application
|
||||
);
|
||||
|
||||
$page = visit($url);
|
||||
// Going straight to SPA (from dynamic) flips is_spa and regenerates nginx.
|
||||
selectListboxOption($page, 'siteType', 'SPA (single-page application)', 2.5);
|
||||
|
||||
$page->assertSee('Custom Nginx configuration')
|
||||
->screenshot(filename: 'toggle-site-type-spa-nginx');
|
||||
|
||||
$this->application->refresh();
|
||||
expect($this->application->settings->is_static)->toBeTrue()
|
||||
->and($this->application->settings->is_spa)->toBeTrue()
|
||||
->and((string) $this->application->custom_nginx_configuration)
|
||||
->toContain('try_files $uri $uri/ /index.html');
|
||||
});
|
||||
|
||||
it('switches from spa back to static and regenerates static nginx config', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$url = applicationConfigurationUrl(
|
||||
$this->stack['project'],
|
||||
$this->stack['environment'],
|
||||
$this->application
|
||||
);
|
||||
|
||||
$page = visit($url);
|
||||
selectListboxOption($page, 'siteType', 'SPA (single-page application)', 2.5);
|
||||
$this->application->refresh();
|
||||
expect($this->application->settings->is_spa)->toBeTrue();
|
||||
|
||||
selectListboxOption($page, 'siteType', 'Static', 2.5);
|
||||
|
||||
$page->assertSee('Custom Nginx configuration')
|
||||
->screenshot(filename: 'toggle-site-type-static-nginx-from-spa');
|
||||
|
||||
$this->application->refresh();
|
||||
$nginx = (string) $this->application->custom_nginx_configuration;
|
||||
expect($this->application->settings->is_static)->toBeTrue()
|
||||
->and($this->application->settings->is_spa)->toBeFalse()
|
||||
->and($nginx)->toContain('try_files $uri $uri.html $uri/index.html $uri/index.htm $uri/ =404')
|
||||
->and($nginx)->not->toContain('try_files $uri $uri/ /index.html');
|
||||
});
|
||||
it('returns to dynamic site type and hides nginx configuration section', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$url = applicationConfigurationUrl(
|
||||
$this->stack['project'],
|
||||
$this->stack['environment'],
|
||||
$this->application
|
||||
);
|
||||
|
||||
$page = visit($url);
|
||||
selectListboxOption($page, 'siteType', 'Static', 2);
|
||||
$page->assertSee('Custom Nginx configuration');
|
||||
|
||||
selectListboxOption($page, 'siteType', 'Dynamic', 2);
|
||||
|
||||
$page->assertDontSee('Custom Nginx configuration')
|
||||
->screenshot(filename: 'toggle-site-type-back-to-dynamic');
|
||||
|
||||
$this->application->refresh();
|
||||
expect($this->application->settings->is_static)->toBeFalse()
|
||||
->and($this->application->settings->is_spa)->toBeFalse();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Database: SSL enable + every ssl_mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('enables postgres ssl from the status listbox', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$url = databaseConfigurationUrl(
|
||||
$this->stack['project'],
|
||||
$this->stack['environment'],
|
||||
$this->postgres
|
||||
);
|
||||
|
||||
$page = visit($url);
|
||||
$page->assertSee('SSL')
|
||||
->assertSee('SSL mode')
|
||||
->screenshot(filename: 'toggle-ssl-before-enable');
|
||||
|
||||
selectListboxOption($page, 'enableSsl', 'Enabled', 2);
|
||||
|
||||
$this->postgres->refresh();
|
||||
expect((bool) $this->postgres->enable_ssl)->toBeTrue();
|
||||
|
||||
$page->assertSee('SSL mode')
|
||||
->screenshot(filename: 'toggle-ssl-enabled');
|
||||
});
|
||||
|
||||
it('cycles through every postgres ssl mode while ssl is enabled', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
// Pre-enable so each mode change only hits sslMode listbox.
|
||||
$this->postgres->enable_ssl = true;
|
||||
$this->postgres->ssl_mode = 'prefer';
|
||||
$this->postgres->save();
|
||||
|
||||
$url = databaseConfigurationUrl(
|
||||
$this->stack['project'],
|
||||
$this->stack['environment'],
|
||||
$this->postgres
|
||||
);
|
||||
|
||||
$page = visit($url);
|
||||
$page->assertSee('SSL mode');
|
||||
|
||||
$modes = [
|
||||
'allow' => 'allow (insecure)',
|
||||
'prefer' => 'prefer (secure)',
|
||||
'require' => 'require (secure)',
|
||||
'verify-ca' => 'verify-ca (secure)',
|
||||
'verify-full' => 'verify-full (secure)',
|
||||
];
|
||||
|
||||
foreach ($modes as $value => $label) {
|
||||
selectListboxOption($page, 'sslMode', $label, 2);
|
||||
|
||||
$this->postgres->refresh();
|
||||
expect($this->postgres->ssl_mode)->toBe($value)
|
||||
->and((bool) $this->postgres->enable_ssl)->toBeTrue();
|
||||
|
||||
// Trigger label should update to the selected mode (parens break assertSee CSS).
|
||||
$triggerText = $page->script(<<<'JS'
|
||||
(() => (document.querySelector("#sslMode-trigger")?.innerText || "").replace(/\s+/g, " ").trim())()
|
||||
JS);
|
||||
expect($triggerText)->toBe($label);
|
||||
|
||||
$page->screenshot(filename: "toggle-ssl-mode-{$value}");
|
||||
}
|
||||
|
||||
// Final reload confirms last mode sticks.
|
||||
$reloaded = visit($url);
|
||||
$triggerText = $reloaded->script(<<<'JS'
|
||||
(() => (document.querySelector("#sslMode-trigger")?.innerText || "").replace(/\s+/g, " ").trim())()
|
||||
JS);
|
||||
expect($triggerText)->toBe('verify-full (secure)');
|
||||
$reloaded->screenshot(filename: 'toggle-ssl-mode-verify-full-reloaded');
|
||||
|
||||
$this->postgres->refresh();
|
||||
expect($this->postgres->ssl_mode)->toBe('verify-full');
|
||||
});
|
||||
it('disables postgres ssl after modes have been set', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$this->postgres->enable_ssl = true;
|
||||
$this->postgres->ssl_mode = 'require';
|
||||
$this->postgres->save();
|
||||
|
||||
$url = databaseConfigurationUrl(
|
||||
$this->stack['project'],
|
||||
$this->stack['environment'],
|
||||
$this->postgres
|
||||
);
|
||||
|
||||
$page = visit($url);
|
||||
selectListboxOption($page, 'enableSsl', 'Disabled', 2);
|
||||
|
||||
$this->postgres->refresh();
|
||||
expect((bool) $this->postgres->enable_ssl)->toBeFalse()
|
||||
// Mode is retained for when SSL is re-enabled.
|
||||
->and($this->postgres->ssl_mode)->toBe('require');
|
||||
|
||||
$page->screenshot(filename: 'toggle-ssl-disabled');
|
||||
});
|
||||
|
||||
it('keeps ssl mode selector disabled while database is running', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$this->postgres->status = 'running:healthy';
|
||||
$this->postgres->enable_ssl = false;
|
||||
$this->postgres->save();
|
||||
|
||||
$url = databaseConfigurationUrl(
|
||||
$this->stack['project'],
|
||||
$this->stack['environment'],
|
||||
$this->postgres
|
||||
);
|
||||
|
||||
$page = visit($url);
|
||||
|
||||
// Triggers should be disabled when not exited.
|
||||
$enableDisabled = $page->script('(() => document.querySelector("#enableSsl-trigger")?.disabled === true)()');
|
||||
$modeDisabled = $page->script('(() => document.querySelector("#sslMode-trigger")?.disabled === true)()');
|
||||
|
||||
expect($enableDisabled)->toBeTrue()
|
||||
->and($modeDisabled)->toBeTrue();
|
||||
|
||||
$page->screenshot(filename: 'toggle-ssl-disabled-while-running');
|
||||
});
|
||||
@@ -8,45 +8,76 @@ use Illuminate\Support\Facades\Hash;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::create(['id' => 0, 'is_sponsorship_popup_enabled' => false]);
|
||||
// `id` is not mass-assignable; forceCreate so InstanceSettings::get() (id 0) works.
|
||||
InstanceSettings::forceCreate([
|
||||
'id' => 0,
|
||||
'is_sponsorship_popup_enabled' => false,
|
||||
'is_registration_enabled' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows registration page when no users exist', function () {
|
||||
it('redirects to root registration when no users exist', function () {
|
||||
$page = visit('/login');
|
||||
|
||||
$page->assertSee('Root User Setup')
|
||||
->assertSee('Create Account')
|
||||
->screenshot();
|
||||
$page->assertPathIs('/register')
|
||||
->assertSee('Create the root account for this instance.')
|
||||
->assertSee('Full instance access')
|
||||
->assertSee('Create account')
|
||||
->screenshot(filename: 'login-no-users-redirects-to-register');
|
||||
});
|
||||
|
||||
it('shows the login form when a user exists', function () {
|
||||
createRootUser();
|
||||
|
||||
$page = visit('/login');
|
||||
|
||||
$page->assertPathIs('/login')
|
||||
->assertSee('Sign in to manage your applications and infrastructure.')
|
||||
->assertSee('Email')
|
||||
->assertSee('Password')
|
||||
->assertSee('Login')
|
||||
->screenshot(filename: 'login-form');
|
||||
});
|
||||
|
||||
it('can login with valid credentials', function () {
|
||||
User::factory()->create([
|
||||
'id' => 0,
|
||||
'email' => 'test@example.com',
|
||||
'password' => Hash::make('password'),
|
||||
]);
|
||||
createRootUser();
|
||||
|
||||
$page = visit('/login');
|
||||
|
||||
$page->fill('email', 'test@example.com')
|
||||
$page->screenshot(filename: 'login-valid-before')
|
||||
->fill('email', 'test@example.com')
|
||||
->fill('password', 'password')
|
||||
->screenshot(filename: 'login-valid-filled')
|
||||
->click('Login')
|
||||
->assertSee('Welcome to Coolify')
|
||||
->screenshot();
|
||||
->assertSee('Connect your first server and start deploying in minutes.')
|
||||
->assertSee('Continue')
|
||||
->assertSee('Skip setup')
|
||||
->screenshot(filename: 'login-valid-onboarding');
|
||||
});
|
||||
|
||||
it('fails login with invalid credentials', function () {
|
||||
User::factory()->create([
|
||||
'id' => 0,
|
||||
'email' => 'test@example.com',
|
||||
'password' => Hash::make('password'),
|
||||
]);
|
||||
createRootUser();
|
||||
|
||||
$page = visit('/login');
|
||||
|
||||
$page->fill('email', 'random@email.com')
|
||||
->fill('password', 'wrongpassword123')
|
||||
->click('Login')
|
||||
->assertSee('These credentials do not match our records')
|
||||
->screenshot();
|
||||
->assertPathIs('/login')
|
||||
->assertSee('These credentials do not match our records.')
|
||||
->screenshot(filename: 'login-invalid-credentials');
|
||||
});
|
||||
|
||||
/**
|
||||
* Create the root user (id 0) with known credentials for browser login tests.
|
||||
*/
|
||||
function createRootUser(): User
|
||||
{
|
||||
return User::forceCreate([
|
||||
'id' => 0,
|
||||
'name' => 'Root User',
|
||||
'email' => 'test@example.com',
|
||||
'password' => Hash::make('password'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\ProxyStatus;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->stack = seedBrowserResourceStack([
|
||||
'projectUuid' => 'project-core-1',
|
||||
'projectName' => 'Core Project Alpha',
|
||||
'projectDescription' => 'Primary browser project',
|
||||
'serverName' => 'localhost',
|
||||
'serverDescription' => 'Local testing host',
|
||||
]);
|
||||
|
||||
Project::create([
|
||||
'uuid' => 'project-core-2',
|
||||
'name' => 'Core Project Beta',
|
||||
'description' => 'Secondary browser project',
|
||||
'team_id' => 0,
|
||||
]);
|
||||
|
||||
Server::create([
|
||||
'uuid' => 'server-core-2',
|
||||
'name' => 'remote-web',
|
||||
'description' => 'Remote web server',
|
||||
'ip' => '10.0.0.50',
|
||||
'team_id' => 0,
|
||||
'private_key_id' => 1,
|
||||
'proxy' => [
|
||||
'type' => ProxyTypes::TRAEFIK->value,
|
||||
'status' => ProxyStatus::EXITED->value,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->application = createBrowserApplication($this->stack, [
|
||||
'uuid' => 'app-core-env',
|
||||
'name' => 'Env Listed App',
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows dashboard with projects and servers after login', function () {
|
||||
$page = loginAndSkipBoarding();
|
||||
|
||||
$page->assertSee('Dashboard')
|
||||
->assertSee('Core Project Alpha')
|
||||
->assertSee('Core Project Beta')
|
||||
->assertSee('localhost')
|
||||
->assertSee('remote-web')
|
||||
->screenshot(filename: 'core-dashboard');
|
||||
});
|
||||
|
||||
it('navigates to project environments page', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$project = $this->stack['project'];
|
||||
$page = visit("/project/{$project->uuid}");
|
||||
|
||||
$page->assertSee('Core Project Alpha')
|
||||
->assertSee('environment')
|
||||
->screenshot(filename: 'core-project-environments');
|
||||
});
|
||||
|
||||
it('navigates to environment resources listing applications', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$project = $this->stack['project'];
|
||||
$environment = $this->stack['environment'];
|
||||
|
||||
$page = visit("/project/{$project->uuid}/environment/{$environment->uuid}");
|
||||
|
||||
$page->assertSee('Env Listed App')
|
||||
->screenshot(filename: 'core-environment-resources');
|
||||
});
|
||||
|
||||
it('shows server configuration page for owner', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$server = $this->stack['server'];
|
||||
$page = visit("/server/{$server->uuid}");
|
||||
|
||||
$page->assertSee('localhost')
|
||||
->assertSee('General')
|
||||
->assertSee('Configuration')
|
||||
->assertSee('Save')
|
||||
->screenshot(filename: 'core-server-configuration');
|
||||
});
|
||||
|
||||
it('shows team general and members pages', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
visit('/team')
|
||||
->assertSee('General')
|
||||
->assertSee('Danger Zone')
|
||||
->screenshot(filename: 'core-team-general');
|
||||
|
||||
visit('/team/members')
|
||||
->assertSee('Members')
|
||||
->assertSee('Invite a member')
|
||||
->screenshot(filename: 'core-team-members');
|
||||
});
|
||||
|
||||
it('shows projects index', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
visit('/projects')
|
||||
->assertSee('Core Project Alpha')
|
||||
->assertSee('Core Project Beta')
|
||||
->screenshot(filename: 'core-projects-index');
|
||||
});
|
||||
|
||||
it('shows servers index', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
visit('/servers')
|
||||
->assertSee('localhost')
|
||||
->assertSee('remote-web')
|
||||
->screenshot(filename: 'core-servers-index');
|
||||
});
|
||||
|
||||
it('protects core routes when unauthenticated', function () {
|
||||
visit('/dashboard')->assertPathIs('/login');
|
||||
visit('/projects')->assertPathIs('/login');
|
||||
visit('/servers')->assertPathIs('/login');
|
||||
visit('/team')->assertPathIs('/login');
|
||||
|
||||
$project = $this->stack['project'];
|
||||
visit("/project/{$project->uuid}")->assertPathIs('/login');
|
||||
|
||||
$server = $this->stack['server'];
|
||||
visit("/server/{$server->uuid}")->assertPathIs('/login')
|
||||
->screenshot(filename: 'core-unauthenticated-redirect');
|
||||
});
|
||||
@@ -1,21 +1,21 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::create(['id' => 0, 'is_sponsorship_popup_enabled' => false]);
|
||||
seedBrowserInstanceSettings();
|
||||
});
|
||||
|
||||
it('shows registration page when no users exist', function () {
|
||||
$page = visit('/register');
|
||||
|
||||
$page->assertSee('Root User Setup')
|
||||
->assertSee('Create Account')
|
||||
->screenshot();
|
||||
$page->assertSee('Create the root account for this instance.')
|
||||
->assertSee('Full instance access')
|
||||
->assertSee('Create account')
|
||||
->screenshot(filename: 'registration-root-setup');
|
||||
});
|
||||
|
||||
it('can register a new root user', function () {
|
||||
@@ -25,9 +25,9 @@ it('can register a new root user', function () {
|
||||
->fill('email', 'root@example.com')
|
||||
->fill('password', 'Password1!@')
|
||||
->fill('password_confirmation', 'Password1!@')
|
||||
->click('Create Account')
|
||||
->click('Create account')
|
||||
->assertPathIs('/onboarding')
|
||||
->screenshot();
|
||||
->screenshot(filename: 'registration-success-onboarding');
|
||||
|
||||
expect(User::where('email', 'root@example.com')->exists())->toBeTrue();
|
||||
});
|
||||
@@ -39,9 +39,9 @@ it('fails registration with mismatched passwords', function () {
|
||||
->fill('email', 'root@example.com')
|
||||
->fill('password', 'Password1!@')
|
||||
->fill('password_confirmation', 'DifferentPass1!@')
|
||||
->click('Create Account')
|
||||
->click('Create account')
|
||||
->assertSee('password')
|
||||
->screenshot();
|
||||
->screenshot(filename: 'registration-password-mismatch');
|
||||
});
|
||||
|
||||
it('fails registration with weak password', function () {
|
||||
@@ -51,17 +51,17 @@ it('fails registration with weak password', function () {
|
||||
->fill('email', 'root@example.com')
|
||||
->fill('password', 'short')
|
||||
->fill('password_confirmation', 'short')
|
||||
->click('Create Account')
|
||||
->click('Create account')
|
||||
->assertSee('password')
|
||||
->screenshot();
|
||||
->screenshot(filename: 'registration-weak-password');
|
||||
});
|
||||
|
||||
it('shows login link when a user already exists', function () {
|
||||
User::factory()->create(['id' => 0]);
|
||||
createBrowserRootUser();
|
||||
|
||||
$page = visit('/register');
|
||||
|
||||
$page->assertSee('Already registered?')
|
||||
->assertDontSee('Root User Setup')
|
||||
->screenshot();
|
||||
$page->assertSee('Already have an account?')
|
||||
->assertDontSee('Full instance access')
|
||||
->screenshot(filename: 'registration-existing-user');
|
||||
});
|
||||
|
||||
@@ -1,30 +1,16 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\ProxyStatus;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Models\Application;
|
||||
use App\Models\DiscordNotificationSettings;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::create(['id' => 0, 'is_sponsorship_popup_enabled' => false]);
|
||||
|
||||
$this->user = User::factory()->create([
|
||||
'id' => 0,
|
||||
'name' => 'Root User',
|
||||
'email' => 'test@example.com',
|
||||
'password' => Hash::make('password'),
|
||||
$this->stack = seedBrowserResourceStack([
|
||||
'projectUuid' => 'project-resource-persistence',
|
||||
'projectName' => 'Resource Persistence',
|
||||
'projectDescription' => 'Browser persistence tests',
|
||||
]);
|
||||
|
||||
DiscordNotificationSettings::where('team_id', 0)->update([
|
||||
@@ -32,149 +18,84 @@ beforeEach(function () {
|
||||
'discord_webhook_url' => 'https://discord.com/test',
|
||||
]);
|
||||
|
||||
PrivateKey::create([
|
||||
'id' => 1,
|
||||
'uuid' => 'ssh-test',
|
||||
'team_id' => 0,
|
||||
'name' => 'Test Key',
|
||||
'description' => 'Test SSH key',
|
||||
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----',
|
||||
]);
|
||||
$this->user = $this->stack['user'];
|
||||
$this->server = $this->stack['server'];
|
||||
$this->project = $this->stack['project'];
|
||||
$this->environment = $this->stack['environment'];
|
||||
$this->destination = $this->stack['destination'];
|
||||
|
||||
$this->server = Server::create([
|
||||
'id' => 0,
|
||||
'uuid' => 'localhost',
|
||||
'name' => 'localhost',
|
||||
'description' => 'Test docker container in development',
|
||||
'ip' => 'coolify-testing-host',
|
||||
'team_id' => 0,
|
||||
'private_key_id' => 1,
|
||||
'proxy' => [
|
||||
'type' => ProxyTypes::TRAEFIK->value,
|
||||
'status' => ProxyStatus::EXITED->value,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->project = Project::create([
|
||||
'uuid' => 'project-resource-persistence',
|
||||
'name' => 'Resource Persistence',
|
||||
'description' => 'Browser persistence tests',
|
||||
'team_id' => 0,
|
||||
]);
|
||||
|
||||
$this->environment = $this->project->environments()->first();
|
||||
|
||||
StandaloneDocker::withoutEvents(function () {
|
||||
$this->destination = StandaloneDocker::firstOrCreate(
|
||||
['server_id' => $this->server->id, 'network' => 'coolify'],
|
||||
['uuid' => 'docker-destination-1', 'name' => 'docker-destination-1']
|
||||
);
|
||||
});
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
$this->application = createBrowserApplication($this->stack, [
|
||||
'uuid' => 'app-resource-persistence',
|
||||
'name' => 'App Before Browser Save',
|
||||
'git_repository' => 'https://github.com/coollabsio/coolify.git',
|
||||
'git_branch' => 'main',
|
||||
'build_pack' => 'nixpacks',
|
||||
'ports_exposes' => '3000',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$this->database = StandalonePostgresql::create([
|
||||
$this->database = createBrowserPostgresql($this->stack, [
|
||||
'uuid' => 'db-resource-persistence',
|
||||
'name' => 'Database Before Browser Save',
|
||||
'description' => 'Initial database description',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'postgres-password',
|
||||
'postgres_db' => 'postgres',
|
||||
'image' => 'postgres:15-alpine',
|
||||
'status' => 'exited',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
});
|
||||
|
||||
it('saves application name and enables static site with nginx config', function () {
|
||||
it('saves application name and custom docker run options from general form', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$updatedName = 'App Saved '.(string) new Cuid2;
|
||||
$applicationRoute = "/project/{$this->project->uuid}/environment/{$this->environment->uuid}/application/{$this->application->uuid}";
|
||||
|
||||
$page = visit($applicationRoute);
|
||||
$page->screenshot();
|
||||
$page->screenshot(filename: 'resource-app-before-save');
|
||||
|
||||
$page->assertSee('General')
|
||||
->assertDontSee('Custom Nginx Configuration')
|
||||
->fill('name', $updatedName)
|
||||
->fill('customDockerRunOptions', '--read-only');
|
||||
->fill('description', 'Saved by browser persistence test')
|
||||
->fill('customDockerRunOptions', '--hostname=persist-app --read-only');
|
||||
|
||||
submitLivewireForm($page);
|
||||
$page->click('[id^="isStatic"]')
|
||||
->wait(2)
|
||||
->screenshot();
|
||||
|
||||
$page->assertSourceHas('Custom Nginx Configuration')
|
||||
->assertSee('Is it a SPA (Single Page Application)?')
|
||||
->assertValue('name', $updatedName);
|
||||
$page->assertValue('name', $updatedName)
|
||||
->screenshot(filename: 'resource-app-after-save');
|
||||
|
||||
$this->application->refresh();
|
||||
expect($this->application->name)->toBe($updatedName)
|
||||
->and($this->application->custom_docker_run_options)->toBe('--read-only')
|
||||
->and($this->application->settings->is_static)->toBeTrue();
|
||||
->and($this->application->description)->toBe('Saved by browser persistence test')
|
||||
->and($this->application->custom_docker_run_options)->toBe('--hostname=persist-app --read-only');
|
||||
|
||||
$reloadedPage = visit($applicationRoute);
|
||||
$reloadedPage->screenshot();
|
||||
|
||||
$reloadedPage->assertValue('name', $updatedName)
|
||||
->assertSourceHas('Custom Nginx Configuration')
|
||||
->assertSourceHas('Is it a SPA (Single Page Application)?');
|
||||
->assertValue('customDockerRunOptions', '--hostname=persist-app --read-only')
|
||||
->screenshot(filename: 'resource-app-reloaded');
|
||||
});
|
||||
|
||||
it('saves database name and enables ssl with mode selector', function () {
|
||||
it('saves database name description and docker options from general form', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$updatedDatabaseName = 'Database Saved '.(string) new Cuid2;
|
||||
$databaseRoute = "/project/{$this->project->uuid}/environment/{$this->environment->uuid}/database/{$this->database->uuid}";
|
||||
|
||||
$page = visit($databaseRoute);
|
||||
$page->screenshot();
|
||||
$page->screenshot(filename: 'resource-db-before-save');
|
||||
|
||||
$page->assertSee('General')
|
||||
->assertDontSee('SSL Mode')
|
||||
->fill('name', $updatedDatabaseName)
|
||||
->fill('description', 'Updated by browser test');
|
||||
->fill('description', 'Updated by browser test')
|
||||
->fill('customDockerRunOptions', '--hostname=persist-db --shm-size=128m');
|
||||
|
||||
submitLivewireForm($page);
|
||||
$page->click('[id^="enableSsl"]');
|
||||
|
||||
$page->assertSee('SSL Mode')
|
||||
->assertValue('name', $updatedDatabaseName);
|
||||
$page->screenshot();
|
||||
$page->assertValue('name', $updatedDatabaseName)
|
||||
->screenshot(filename: 'resource-db-after-save');
|
||||
|
||||
$this->database->refresh();
|
||||
expect($this->database->name)->toBe($updatedDatabaseName)
|
||||
->and($this->database->description)->toBe('Updated by browser test')
|
||||
->and($this->database->enable_ssl)->toBeTruthy();
|
||||
->and($this->database->custom_docker_run_options)->toBe('--hostname=persist-db --shm-size=128m');
|
||||
|
||||
$compose = convertDockerRunToCompose($this->database->custom_docker_run_options);
|
||||
expect(data_get($compose, 'hostname'))->toBe('persist-db')
|
||||
->and(data_get($compose, 'shm_size'))->toBe('128m');
|
||||
|
||||
$reloadedPage = visit($databaseRoute);
|
||||
$reloadedPage->screenshot();
|
||||
|
||||
$reloadedPage->assertValue('name', $updatedDatabaseName)
|
||||
->assertSee('SSL Mode');
|
||||
->assertValue('description', 'Updated by browser test')
|
||||
->screenshot(filename: 'resource-db-reloaded');
|
||||
});
|
||||
|
||||
function submitLivewireForm($page): void
|
||||
{
|
||||
$page->script("document.querySelector('form[wire\\\\:submit=\"submit\"]')?.requestSubmit()");
|
||||
$page->wait(1);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->stack = seedBrowserResourceStack();
|
||||
$created = createBrowserService($this->stack, [
|
||||
'uuid' => 'svc-browser-config',
|
||||
'name' => 'Config Service',
|
||||
'description' => 'Compose stack for browser tests',
|
||||
'docker_compose_raw' => <<<'YAML'
|
||||
services:
|
||||
web:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- '80'
|
||||
api:
|
||||
image: httpd:alpine
|
||||
YAML,
|
||||
]);
|
||||
$this->service = $created['service'];
|
||||
$this->serviceApplication = $created['serviceApplication'];
|
||||
});
|
||||
|
||||
it('shows service configuration with compose resources', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$url = serviceConfigurationUrl(
|
||||
$this->stack['project'],
|
||||
$this->stack['environment'],
|
||||
$this->service
|
||||
);
|
||||
|
||||
$page = visit($url);
|
||||
|
||||
$page->assertSee('Config Service')
|
||||
->assertSee('General')
|
||||
->assertSee('Environment Variables')
|
||||
->assertSee('Compose resources')
|
||||
->assertSee('web')
|
||||
->screenshot(filename: 'service-configuration-overview');
|
||||
});
|
||||
|
||||
it('saves service name and description', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$updatedName = 'Service UI '.(string) new Cuid2;
|
||||
$url = serviceConfigurationUrl(
|
||||
$this->stack['project'],
|
||||
$this->stack['environment'],
|
||||
$this->service
|
||||
);
|
||||
|
||||
$page = visit($url);
|
||||
$page->fill('name', $updatedName)
|
||||
->fill('description', 'Updated service description')
|
||||
->screenshot(filename: 'service-before-save');
|
||||
|
||||
// StackForm Livewire component owns name/description save.
|
||||
submitLivewireForm($page, 'submit');
|
||||
$page->wait(2);
|
||||
|
||||
$this->service->refresh();
|
||||
expect($this->service->name)->toBe($updatedName)
|
||||
->and($this->service->description)->toBe('Updated service description');
|
||||
|
||||
visit($url)
|
||||
->assertValue('name', $updatedName)
|
||||
->screenshot(filename: 'service-after-reload');
|
||||
});
|
||||
|
||||
it('opens service environment variables domains and storages pages', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$base = serviceConfigurationUrl(
|
||||
$this->stack['project'],
|
||||
$this->stack['environment'],
|
||||
$this->service
|
||||
);
|
||||
|
||||
visit("{$base}/environment-variables")
|
||||
->assertSee('Environment Variables')
|
||||
->screenshot(filename: 'service-environment-variables');
|
||||
|
||||
visit("{$base}/domains")
|
||||
->assertSee('Config Service')
|
||||
->screenshot(filename: 'service-domains');
|
||||
|
||||
visit("{$base}/storages")
|
||||
->assertSee('Config Service')
|
||||
->screenshot(filename: 'service-storages');
|
||||
});
|
||||
|
||||
it('opens compose stack application general settings', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$project = $this->stack['project'];
|
||||
$environment = $this->stack['environment'];
|
||||
$service = $this->service;
|
||||
$stackUuid = $this->serviceApplication->uuid;
|
||||
|
||||
$page = visit("/project/{$project->uuid}/environment/{$environment->uuid}/service/{$service->uuid}/{$stackUuid}");
|
||||
|
||||
$page->assertSee('web')
|
||||
->assertSee('General')
|
||||
->screenshot(filename: 'service-stack-application-general');
|
||||
});
|
||||
|
||||
it('lists the service on the environment resources page', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$project = $this->stack['project'];
|
||||
$environment = $this->stack['environment'];
|
||||
|
||||
visit("/project/{$project->uuid}/environment/{$environment->uuid}")
|
||||
->assertSee('Config Service')
|
||||
->screenshot(filename: 'environment-lists-service');
|
||||
});
|
||||
|
||||
it('shows service danger zone', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$base = serviceConfigurationUrl(
|
||||
$this->stack['project'],
|
||||
$this->stack['environment'],
|
||||
$this->service
|
||||
);
|
||||
|
||||
visit("{$base}/danger")
|
||||
->assertSee('Danger')
|
||||
->assertSee('Config Service')
|
||||
->screenshot(filename: 'service-danger-zone');
|
||||
});
|
||||
Reference in New Issue
Block a user