Merge branch 'next' into main

This commit is contained in:
Andras Bacsai
2026-08-21 12:11:40 +02:00
committed by GitHub
139 changed files with 5343 additions and 865 deletions
-27
View File
@@ -1,27 +0,0 @@
<?php
namespace Tests\Browser;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
use Throwable;
class LoginTest extends DuskTestCase
{
/**
* A basic test for the login page.
* Login with the test user and assert that the user is redirected to the dashboard.
*
* @return void
*
* @throws Throwable
*/
public function test_login()
{
$this->browse(callback: function (Browser $browser) {
$browser->loginWithRootUser()
->assertPathIs('/')
->assertSee('Dashboard');
});
}
}
@@ -1,34 +0,0 @@
<?php
namespace Tests\Browser;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
use Throwable;
class ProjectAddNewTest extends DuskTestCase
{
/**
* A basic test for the projects page.
* Login with the test user and assert that the user is redirected to the projects page.
*
* @return void
*
* @throws Throwable
*/
public function test_login()
{
$this->browse(function (Browser $browser) {
$browser->loginWithRootUser()
->visit('/projects')
->pressAndWaitFor('+ Add', 1)
->assertSee('New Project')
->screenshot('project-add-new-1')
->type('name', 'Test Project')
->screenshot('project-add-new-2')
->press('Continue')
->assertSee('Test Project.')
->screenshot('project-add-new-3');
});
}
}
@@ -1,29 +0,0 @@
<?php
namespace Tests\Browser;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
use Throwable;
class ProjectSearchTest extends DuskTestCase
{
/**
* A basic test for the projects page.
* Login with the test user and assert that the user is redirected to the projects page.
*
* @return void
*
* @throws Throwable
*/
public function test_login()
{
$this->browse(function (Browser $browser) {
$browser->loginWithRootUser()
->visit('/projects')
->type('[x-model="search"]', 'joi43j4oi32j4o2')
->assertSee('No project found with the search term "joi43j4oi32j4o2".')
->screenshot('project-search-not-found');
});
}
}
-27
View File
@@ -1,27 +0,0 @@
<?php
namespace Tests\Browser;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
use Throwable;
class ProjectTest extends DuskTestCase
{
/**
* A basic test for the projects page.
* Login with the test user and assert that the user is redirected to the projects page.
*
* @return void
*
* @throws Throwable
*/
public function test_login()
{
$this->browse(function (Browser $browser) {
$browser->loginWithRootUser()
->visit('/projects')
->assertSee('Projects');
});
}
}
-2
View File
@@ -1,2 +0,0 @@
*
!.gitignore
-2
View File
@@ -1,2 +0,0 @@
*
!.gitignore
-57
View File
@@ -1,57 +0,0 @@
<?php
namespace Tests;
use Facebook\WebDriver\Chrome\ChromeOptions;
use Facebook\WebDriver\Remote\DesiredCapabilities;
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Illuminate\Support\Collection;
use Laravel\Dusk\TestCase as BaseTestCase;
abstract class DuskTestCase extends BaseTestCase
{
use CreatesApplication;
/**
* Prepare for Dusk test execution.
*
* @beforeClass
*/
public static function prepare(): void
{
if (! static::runningInSail()) {
static::startChromeDriver();
}
}
/**
* Create the RemoteWebDriver instance.
*/
protected function driver(): RemoteWebDriver
{
$options = (new ChromeOptions)->addArguments(collect([
$this->shouldStartMaximized() ? '--start-maximized' : '--window-size=1920,1080',
])->unless($this->hasHeadlessDisabled(), function (Collection $items) {
return $items->merge([
'--disable-gpu',
'--headless=new',
]);
})->all());
return RemoteWebDriver::create(
'http://localhost:4444',
DesiredCapabilities::chrome()->setCapability(
ChromeOptions::CAPABILITY,
$options
)
);
}
/**
* Determine if the browser window should start maximized.
*/
protected function baseUrl()
{
return 'http://localhost:8000';
}
}
@@ -18,7 +18,7 @@ use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true]);
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
@@ -103,6 +103,11 @@ test('admin sees unlocked env value in Show component', function () {
'type' => 'application',
]);
// Values hydrate lazily; the edit modal triggers loadValues() on open.
expect($component->get('value'))->toBeNull();
$component->call('loadValues');
expect($component->get('value'))->toBe('secret-unlocked-value');
});
@@ -246,6 +251,17 @@ test('API hides env values for member even with read:sensitive token', function
'Authorization' => 'Bearer '.$token->plainTextToken,
])->getJson("/api/v1/applications/{$this->application->uuid}/envs");
$response->assertForbidden();
});
test('API hides env values for member with read token', function () {
session(['currentTeam' => $this->team]);
$token = $this->member->createToken('member-read', ['read']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token->plainTextToken,
])->getJson("/api/v1/applications/{$this->application->uuid}/envs");
$response->assertOk();
$envs = collect($response->json());
+32 -6
View File
@@ -1,16 +1,42 @@
<?php
it('renders a reusable compact copy button', function () {
it('renders a self-contained clipboard button for backend-provided values', function () {
$html = $this->blade('<x-copy-button value="backup/path.sql" label="Copy backup path" />');
$html->assertSee('Copy backup path')
->assertSee('backup\/path.sql', false)
->assertSee('window.copyToClipboard', false)
->assertSee('size-6', false);
->assertSee('x-data="copyButton"', false)
->assertDontSee('window.copyToClipboard', false);
});
it('uses the reusable copy button for database backup paths', function () {
$view = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php'));
it('disables the button when no backend value is available', function () {
$html = $this->blade('<x-copy-button :value="null" />');
expect($view)->toContain('<x-copy-button :value="data_get($execution, \'filename\', \'\')" label="Copy backup path" />');
$html->assertSee('disabled', false);
});
it('evaluates a resolve expression at click time instead of a static value', function () {
$html = $this->blade('<x-copy-button resolve="$wire.copyValue()" />');
$html->assertSee('await ($wire.copyValue())', false)
->assertDontSee('disabled', false);
});
it('is the single clipboard implementation shared by its call sites', function () {
expect(file_get_contents(resource_path('js/copy-button.js')))
->toContain("window.Alpine.data('copyButton'");
expect(file_get_contents(resource_path('js/app.js')))
->toContain('initializeCopyButtonComponent');
$modalConfirmation = file_get_contents(resource_path('views/components/modal-confirmation.blade.php'));
$backupExecutions = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php'));
expect($modalConfirmation)
->toContain('<x-copy-button resolve="decodedText"')
->not->toContain('navigator.clipboard');
expect($backupExecutions)
->toContain('<x-copy-button :value="data_get($execution, \'filename\', \'\')" label="Copy backup path"')
->not->toContain('navigator.clipboard');
});
+179
View File
@@ -0,0 +1,179 @@
<?php
use App\Livewire\Notifications\Discord;
use App\Livewire\Notifications\Email;
use App\Livewire\Notifications\Pushover;
use App\Livewire\Notifications\Slack;
use App\Livewire\Notifications\Telegram;
use App\Livewire\Notifications\Webhook;
use App\Livewire\SettingsEmail;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Once;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function actingAsEnableActionOwner(): array
{
$team = Team::factory()->create();
$user = User::factory()->create(['email' => 'owner@example.com']);
$user->teams()->attach($team, ['role' => 'owner']);
session(['currentTeam' => $team]);
test()->actingAs($user);
return [$user, $team];
}
function actingAsEnableActionInstanceAdmin(): User
{
$team = Team::forceCreate(['id' => 0, 'name' => 'Root Team', 'personal_team' => true]);
$user = User::factory()->create(['id' => 0, 'email' => 'root-enable-actions@example.com']);
if (! $user->teams()->whereKey($team->id)->exists()) {
$user->teams()->attach($team, ['role' => 'owner']);
}
session(['currentTeam' => $team]);
test()->actingAs($user);
return $user;
}
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
Once::flush();
});
it('renders settings email enable actions instead of enabled checkboxes', function () {
$view = file_get_contents(resource_path('views/livewire/settings-email.blade.php'));
expect($view)->toContain('Enable SMTP Server')
->and($view)->toContain('Disable SMTP Server')
->and($view)->toContain('Enable Resend')
->and($view)->toContain('Disable Resend')
->and($view)->not->toContain('id="smtpEnabled" label="Enabled"')
->and($view)->not->toContain('id="resendEnabled" label="Enabled"');
});
it('keeps transactional smtp disabled when enable validation fails', function () {
actingAsEnableActionInstanceAdmin();
Livewire::test(SettingsEmail::class)
->call('toggleSmtp')
->assertDispatched('error')
->assertSet('smtpEnabled', false);
expect(instanceSettings()->fresh()->smtp_enabled)->toBeFalse();
});
it('enables transactional smtp only after required fields validate', function () {
actingAsEnableActionInstanceAdmin();
Livewire::test(SettingsEmail::class)
->set('smtpFromAddress', 'mail@example.com')
->set('smtpFromName', 'Coolify')
->set('smtpHost', 'smtp.example.com')
->set('smtpPort', '587')
->set('smtpEncryption', 'starttls')
->call('toggleSmtp')
->assertHasNoErrors()
->assertSet('smtpEnabled', true)
->assertSet('resendEnabled', false);
expect(instanceSettings()->fresh()->smtp_enabled)->toBeTrue()
->and(instanceSettings()->fresh()->resend_enabled)->toBeFalse();
});
it('renders notification provider enable actions instead of enabled checkboxes', function (string $view, string $enableLabel, string $checkboxSnippet) {
$contents = file_get_contents(resource_path("views/livewire/notifications/{$view}.blade.php"));
expect($contents)->toContain($enableLabel)
->and($contents)->not->toContain($checkboxSnippet);
})->with([
'discord' => ['discord', 'Enable Discord', 'id="discordEnabled" label="Enabled"'],
'slack' => ['slack', 'Enable Slack', 'id="slackEnabled" label="Enabled"'],
'telegram' => ['telegram', 'Enable Telegram', 'id="telegramEnabled" label="Enabled"'],
'pushover' => ['pushover', 'Enable Pushover', 'id="pushoverEnabled" label="Enabled"'],
'webhook' => ['webhook', 'Enable Webhook', 'id="webhookEnabled" label="Enabled"'],
]);
it('shows notification provider save buttons while disabled', function (string $component) {
actingAsEnableActionOwner();
Livewire::test($component)
->assertSet(str(class_basename($component))->camel()->append('Enabled')->toString(), false)
->assertSee('Save');
})->with([
'discord' => [Discord::class],
'slack' => [Slack::class],
'telegram' => [Telegram::class],
'pushover' => [Pushover::class],
'webhook' => [Webhook::class],
]);
it('hides notification provider test buttons while disabled and shows them when enabled', function (string $component, string $enabledProperty) {
actingAsEnableActionOwner();
Livewire::test($component)
->assertDontSee('Send Test Notification');
Livewire::test($component)
->set($enabledProperty, true)
->assertSee('Send Test Notification');
})->with([
'discord' => [Discord::class, 'discordEnabled'],
'slack' => [Slack::class, 'slackEnabled'],
'telegram' => [Telegram::class, 'telegramEnabled'],
'pushover' => [Pushover::class, 'pushoverEnabled'],
'webhook' => [Webhook::class, 'webhookEnabled'],
]);
it('hides the email test button while email notifications are disabled', function () {
actingAsEnableActionOwner();
Livewire::test(Email::class)
->assertDontSee('Send Test Email');
});
it('keeps notification providers disabled when enable validation fails', function (string $component, string $method, string $enabledProperty, string $requiredField, string $settingsRelation, string $settingsColumn) {
[, $team] = actingAsEnableActionOwner();
Livewire::test($component)
->call($method)
->assertDispatched('error')
->assertSet($enabledProperty, false);
expect($team->{$settingsRelation}->fresh()->{$settingsColumn})->toBeFalse();
})->with([
'discord' => [Discord::class, 'toggleDiscordEnabled', 'discordEnabled', 'discordWebhookUrl', 'discordNotificationSettings', 'discord_enabled'],
'slack' => [Slack::class, 'toggleSlackEnabled', 'slackEnabled', 'slackWebhookUrl', 'slackNotificationSettings', 'slack_enabled'],
'telegram' => [Telegram::class, 'toggleTelegramEnabled', 'telegramEnabled', 'telegramToken', 'telegramNotificationSettings', 'telegram_enabled'],
'pushover' => [Pushover::class, 'togglePushoverEnabled', 'pushoverEnabled', 'pushoverUserKey', 'pushoverNotificationSettings', 'pushover_enabled'],
'webhook' => [Webhook::class, 'toggleWebhookEnabled', 'webhookEnabled', 'webhookUrl', 'webhookNotificationSettings', 'webhook_enabled'],
]);
it('renders notification email and log drain enable actions instead of enabled checkboxes', function () {
$notificationEmail = file_get_contents(resource_path('views/livewire/notifications/email.blade.php'));
$logDrains = file_get_contents(resource_path('views/livewire/server/log-drains.blade.php'));
expect($notificationEmail)->toContain('Enable SMTP Server')
->and($notificationEmail)->toContain('Enable Resend')
->and($notificationEmail)->not->toContain('id="smtpEnabled"')
->and($notificationEmail)->not->toContain('id="resendEnabled"')
->and($logDrains)->toContain('Enable New Relic')
->and($logDrains)->toContain('Enable Axiom')
->and($logDrains)->toContain('Enable Custom FluentBit')
->and($logDrains)->not->toContain('label="Enabled"');
});
it('keeps notification email smtp disabled when enable validation fails', function () {
actingAsEnableActionOwner();
Livewire::test(Email::class)
->call('toggleSmtp')
->assertDispatched('error')
->assertSet('smtpEnabled', false);
});
@@ -71,7 +71,7 @@ it('loads environment variables when loadEnvironmentVariables is called', functi
->assertSee('Loading environment variables...')
->call('loadEnvironmentVariables')
->assertSet('readyToLoad', true)
->assertDontSee('Loading environment variables...')
->assertDontSeeText('Loading environment variables...')
->assertSee('API_KEY');
expect($component->instance()->environmentVariables->pluck('key')->all())
@@ -0,0 +1,151 @@
<?php
use App\Livewire\Project\Shared\EnvironmentVariable\Show;
use App\Livewire\Project\Shared\EnvironmentVariable\ShowHardcoded;
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\SharedEnvironmentVariable;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->team->members()->attach($this->user, ['role' => 'owner']);
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
$this->application = Application::factory()->create(['environment_id' => $this->environment->id]);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
function createEnvironmentVariable(array $attributes = []): EnvironmentVariable
{
return EnvironmentVariable::create(array_merge([
'key' => 'API_KEY',
'value' => 'secret-value',
'resourceable_type' => Application::class,
'resourceable_id' => test()->application->id,
], $attributes));
}
function assertCopiedValue(EnvironmentVariable|SharedEnvironmentVariable $env, ?string $expected): void
{
Livewire::test(Show::class, ['env' => $env, 'type' => 'application'])
->call('copyValue')
->assertReturned($expected);
}
function assertCopiedComposeValue(string $value, ?string $expected): void
{
Livewire::test(ShowHardcoded::class, [
'env' => ['key' => 'MYSQL_USER', 'value' => $value],
'resourceableType' => Application::class,
'resourceableId' => test()->application->id,
])
->call('copyValue')
->assertReturned($expected);
}
test('copies the plain value', function () {
assertCopiedValue(createEnvironmentVariable(), 'secret-value');
});
test('copies the referenced variable value instead of the reference', function (string $reference) {
createEnvironmentVariable(['key' => 'SERVICE_USER_CLASSICPRESS', 'value' => 'classicpress-user']);
assertCopiedValue(createEnvironmentVariable(['key' => 'MYSQL_USER', 'value' => $reference]), 'classicpress-user');
})->with(['bare' => '$SERVICE_USER_CLASSICPRESS', 'braced' => '${SERVICE_USER_CLASSICPRESS}']);
test('copies the resolved shared variable value', function () {
SharedEnvironmentVariable::create([
'key' => 'MY_SECRET',
'value' => 'resolved-secret',
'type' => 'team',
'team_id' => $this->team->id,
]);
assertCopiedValue(createEnvironmentVariable(['value' => '{{team.MY_SECRET}}']), 'resolved-secret');
});
test('copies embedded, literal and unknown references as stored', function () {
createEnvironmentVariable(['key' => 'SERVICE_PASSWORD_MYSQL', 'value' => 'generated-password']);
assertCopiedValue(
createEnvironmentVariable(['key' => 'DATABASE_URL', 'value' => 'mysql://root:$SERVICE_PASSWORD_MYSQL@db:3306']),
'mysql://root:$SERVICE_PASSWORD_MYSQL@db:3306',
);
assertCopiedValue(
createEnvironmentVariable(['key' => 'LITERAL', 'value' => '$SERVICE_PASSWORD_MYSQL', 'is_literal' => true]),
'$SERVICE_PASSWORD_MYSQL',
);
assertCopiedValue(createEnvironmentVariable(['key' => 'UNKNOWN', 'value' => '$DOES_NOT_EXIST']), '$DOES_NOT_EXIST');
});
test('copies literal values without .env-style quoting', function () {
$env = createEnvironmentVariable(['value' => 'pa$$word', 'is_literal' => true]);
expect($env->real_value)->toBe("'pa\$\$word'");
assertCopiedValue($env, 'pa$$word');
});
test('copies the value of a shared environment variable row', function () {
$shared = SharedEnvironmentVariable::create([
'key' => 'TEAM_WIDE',
'value' => 'team-wide-value',
'type' => 'team',
'team_id' => $this->team->id,
]);
assertCopiedValue($shared, 'team-wide-value');
});
test('members get no copy button and no value', function () {
$member = User::factory()->create();
$this->team->members()->attach($member, ['role' => 'member']);
$this->actingAs($member);
Livewire::test(Show::class, ['env' => createEnvironmentVariable(), 'type' => 'application'])
->assertDontSeeHtml('Copy value')
->call('copyValue')
->assertReturned(null);
});
test('locked variables get no copy button and no value', function () {
Livewire::test(Show::class, ['env' => createEnvironmentVariable(['is_shown_once' => true]), 'type' => 'application'])
->assertDontSeeHtml('Copy value')
->call('copyValue')
->assertReturned(null);
});
test('compose-managed rows copy the referenced variable value', function () {
createEnvironmentVariable(['key' => 'SERVICE_USER_CLASSICPRESS', 'value' => 'classicpress-user']);
assertCopiedComposeValue('$SERVICE_USER_CLASSICPRESS', 'classicpress-user');
assertCopiedComposeValue('production', 'production');
});
test('compose-managed rows hide copying from members', function () {
$member = User::factory()->create();
$this->team->members()->attach($member, ['role' => 'member']);
$this->actingAs($member);
Livewire::test(ShowHardcoded::class, [
'env' => ['key' => 'MYSQL_USER', 'value' => '$SERVICE_USER_CLASSICPRESS'],
'resourceableType' => Application::class,
'resourceableId' => $this->application->id,
])
->assertDontSeeHtml('Copy value')
->call('copyValue')
->assertReturned(null);
});
@@ -0,0 +1,45 @@
<?php
use App\Actions\Server\StartLogDrain;
use App\Livewire\Server\LogDrains;
use App\Models\Server;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->user = User::factory()->create();
$this->team = $this->user->teams()->first();
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
it('reverts the persisted enabled flag when starting the log drain fails', function () {
StartLogDrain::mock()->shouldReceive('handle')->andThrow(new RuntimeException('runtime boom'));
expect($this->server->settings->fresh()->is_logdrain_newrelic_enabled)->toBeFalsy();
Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid])
->set('logDrainNewRelicLicenseKey', 'abc123')
->set('logDrainNewRelicBaseUri', 'https://log-api.newrelic.com')
->call('toggleLogDrain', 'newrelic')
->assertSet('isLogDrainNewRelicEnabled', false);
expect($this->server->settings->fresh()->is_logdrain_newrelic_enabled)->toBeFalsy();
});
it('keeps the enabled flag persisted when starting the log drain succeeds', function () {
StartLogDrain::mock()->shouldReceive('handle')->andReturn('ok');
Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid])
->set('logDrainNewRelicLicenseKey', 'abc123')
->set('logDrainNewRelicBaseUri', 'https://log-api.newrelic.com')
->call('toggleLogDrain', 'newrelic')
->assertSet('isLogDrainNewRelicEnabled', true);
expect($this->server->settings->fresh()->is_logdrain_newrelic_enabled)->toBeTruthy();
});
+22
View File
@@ -37,6 +37,28 @@ test('auth pages use the Coollabs purple background glow', function () {
->not->toMatch('/\.auth-shell\s*\{[^}]*color-mix\(in oklab, var\(--color-accent\) 9%, transparent\)/s');
});
test('external login providers are centered and full width', function () {
$login = file_get_contents(resource_path('views/auth/login.blade.php'));
expect($login)
->toContain('class="flex flex-col gap-2"')
->toContain('class="w-full justify-center"')
->not->toContain('sm:w-[calc(50%-0.25rem)]');
});
test('external login providers display their icons except oidc', function () {
$login = file_get_contents(resource_path('views/auth/login.blade.php'));
expect($login)
->toContain("@if (\$provider_setting->provider !== 'oidc')")
->toContain("asset('svgs/'.\$provider_setting->provider.'.svg')")
->toContain('class="size-5 shrink-0 dark:invert"');
foreach (['authentik', 'azure', 'bitbucket', 'clerk', 'discord', 'github', 'gitlab', 'google', 'infomaniak', 'zitadel'] as $provider) {
expect(public_path("svgs/{$provider}.svg"))->toBeFile();
}
});
test('error pages use the Coollabs purple background glow', function () {
$styles = file_get_contents(resource_path('css/app.css'));
+113 -1
View File
@@ -1,25 +1,35 @@
<?php
use App\Models\InstanceSettings;
use App\Models\OauthIdentity;
use App\Models\OauthSetting;
use App\Models\User;
use App\Services\Auth\OauthLoginService;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Once;
use Laravel\Socialite\Facades\Socialite;
use Symfony\Component\HttpKernel\Exception\HttpException;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::create([
InstanceSettings::forceCreate([
'id' => 0,
'is_registration_enabled' => false,
]);
Once::flush();
OauthSetting::create([
'provider' => 'google',
'client_id' => 'client-id',
'client_secret' => 'client-secret',
'redirect_uri' => 'https://coolify.example.com/auth/google/callback',
'tenant' => 'example.com',
'enabled' => true,
]);
});
@@ -46,6 +56,75 @@ it('logs in an existing user when the oauth provider returns a mixed-case email'
$response->assertRedirect('/');
$this->assertAuthenticatedAs($user);
expect(User::count())->toBe(1);
expect(OauthIdentity::where([
'user_id' => $user->id,
'provider' => 'google',
'provider_user_id' => 'google-user-id',
])->exists())->toBeTrue();
});
it('never moves an existing oauth identity when the provider email changes', function () {
config()->set('app.maintenance.driver', 'file');
$identityOwner = User::factory()->create(['email' => 'old@example.com']);
$otherUser = User::factory()->create(['email' => 'new@example.com']);
$identity = OauthIdentity::create([
'user_id' => $identityOwner->id,
'provider' => 'google',
'issuer' => 'google',
'provider_user_id' => 'google-user-id',
'email' => 'old@example.com',
]);
$provider = Mockery::mock();
$provider->shouldReceive('setConfig')->once()->andReturnSelf();
$provider->shouldReceive('with')->once()->with(['hd' => 'example.com'])->andReturnSelf();
$provider->shouldReceive('user')->once()->andReturn((object) [
'email' => 'new@example.com',
'name' => 'Example User',
'id' => 'google-user-id',
]);
Socialite::shouldReceive('driver')->once()->with('google')->andReturn($provider);
$this->get(route('auth.callback', 'google'))->assertRedirect('/');
$this->assertAuthenticatedAs($identityOwner);
expect($identity->refresh()->user_id)->toBe($identityOwner->id)
->and($identity->email)->toBe('new@example.com')
->and($identity->user_id)->not->toBe($otherUser->id);
});
it('continues oauth login when another request creates the identity first', function () {
$user = User::factory()->create(['email' => 'race@example.com']);
$eventName = 'eloquent.creating: '.OauthIdentity::class;
Event::listen($eventName, function (OauthIdentity $identity): void {
$attributes = $identity->getAttributes();
DB::afterRollBack(fn () => DB::table('oauth_identities')->insert($attributes));
throw new UniqueConstraintViolationException(
DB::getDefaultConnection(),
'insert into oauth_identities',
[],
new PDOException('duplicate identity'),
);
});
try {
$resolvedUser = app(OauthLoginService::class)->login('google', (object) [
'email' => 'race@example.com',
'name' => 'Race User',
'id' => 'google-race-id',
], OauthSetting::where('provider', 'google')->firstOrFail());
} finally {
Event::forget($eventName);
}
expect($resolvedUser->is($user))->toBeTrue()
->and(OauthIdentity::where('provider_user_id', 'google-race-id')->count())->toBe(1);
$this->assertAuthenticatedAs($user);
});
it('rejects oauth logins when the provider does not return an email address', function (?string $providerEmail) {
@@ -76,4 +155,37 @@ it('rejects oauth logins when the provider does not return an email address', fu
})->with([
'null email' => [null],
'blank email' => [' '],
'malformed email' => ['not-an-email'],
'missing domain' => ['user@'],
]);
it('rejects oauth logins when the provider does not return a valid user id', function (mixed $invalidId) {
$oauthUser = (object) [
'email' => 'user@example.edu',
'name' => 'Example User',
];
if ($invalidId !== 'missing') {
$oauthUser->id = $invalidId;
}
try {
app(OauthLoginService::class)->login('google', $oauthUser, OauthSetting::where('provider', 'google')->firstOrFail());
} catch (HttpException $exception) {
expect($exception->getStatusCode())->toBe(403)
->and(OauthIdentity::count())->toBe(0)
->and(User::count())->toBe(0);
return;
}
$this->fail('Expected an invalid OAuth provider user ID to be rejected.');
})->with([
'null id' => [null],
'missing id' => ['missing'],
'blank id' => [' '],
'non-scalar id' => [[]],
'true id' => [true],
'false id' => [false],
'float id' => [1.0],
]);
@@ -0,0 +1,52 @@
<?php
use App\Actions\Fortify\CreateNewUser;
use App\Models\InstanceSettings;
use App\Models\OauthSetting;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Once;
use Symfony\Component\HttpKernel\Exception\HttpException;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate([
'id' => 0,
'is_registration_enabled' => true,
'disable_registration_when_oauth_enabled' => true,
]);
Once::flush();
});
it('blocks password registration when oauth registration policy disables it', function () {
OauthSetting::create([
'provider' => 'oidc',
'enabled' => true,
'client_id' => 'client-id',
'client_secret' => 'secret',
'base_url' => 'https://idp.example.com',
]);
app(CreateNewUser::class)->create([
'name' => 'Password User',
'email' => 'password@example.com',
'password' => 'password',
'password_confirmation' => 'password',
]);
})->throws(HttpException::class);
it('allows password registration when no oauth provider is enabled', function () {
OauthSetting::create([
'provider' => 'oidc',
'enabled' => false,
]);
$user = app(CreateNewUser::class)->create([
'name' => 'Password User',
'email' => 'password@example.com',
'password' => 'password',
'password_confirmation' => 'password',
]);
expect($user->email)->toBe('password@example.com');
});
+275
View File
@@ -0,0 +1,275 @@
<?php
use App\Auth\Oidc\OidcUser;
use App\Models\InstanceSettings;
use App\Models\OauthIdentity;
use App\Models\OauthSetting;
use App\Models\Team;
use App\Models\User;
use App\Services\Auth\OauthLoginService;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Once;
use Laravel\Socialite\Facades\Socialite;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('app.maintenance.driver', 'file');
InstanceSettings::forceCreate([
'id' => 0,
'is_registration_enabled' => false,
]);
Once::flush();
OauthSetting::create([
'provider' => 'oidc',
'enabled' => true,
'client_id' => 'client-id',
'client_secret' => 'client-secret',
'base_url' => 'https://idp.example.com',
'redirect_uri' => 'https://coolify.example.com/auth/oidc/callback',
'allow_registration' => false,
]);
});
function fakeOidcProvider(array $claims = []): void
{
$user = (new OidcUser)->setRaw(array_merge([
'iss' => 'https://idp.example.com',
'sub' => 'okta-user-1',
'email' => 'user@example.com',
'email_verified' => true,
'name' => 'Okta User',
], $claims))->map([
'id' => $claims['sub'] ?? 'okta-user-1',
'name' => $claims['name'] ?? 'Okta User',
'email' => $claims['email'] ?? 'user@example.com',
]);
$provider = Mockery::mock();
$provider->shouldReceive('setConfig')->andReturnSelf();
$provider->shouldReceive('user')->andReturn($user);
Socialite::shouldReceive('driver')->with('oidc')->andReturn($provider);
}
it('logs in a user through an existing oidc identity', function () {
$user = User::factory()->create(['email' => 'existing@example.com']);
OauthIdentity::create([
'user_id' => $user->id,
'provider' => 'oidc',
'issuer' => 'https://idp.example.com',
'provider_user_id' => 'okta-user-1',
'email' => 'existing@example.com',
]);
fakeOidcProvider(['email' => 'existing@example.com']);
$response = $this->get(route('auth.callback', 'oidc'));
$response->assertRedirect('/');
$this->assertAuthenticatedAs($user);
});
it('continues oidc login when another request creates the identity first', function () {
$user = User::factory()->create(['email' => 'race@example.com']);
$eventName = 'eloquent.creating: '.OauthIdentity::class;
Event::listen($eventName, function (OauthIdentity $identity): void {
$attributes = $identity->getAttributes();
DB::afterRollBack(fn () => DB::table('oauth_identities')->insert($attributes));
throw new UniqueConstraintViolationException(
DB::getDefaultConnection(),
'insert into oauth_identities',
[],
new PDOException('duplicate identity'),
);
});
try {
$resolvedUser = app(OauthLoginService::class)->login('oidc', (new OidcUser)->setRaw([
'iss' => 'https://idp.example.com',
'sub' => 'oidc-race-id',
'email' => 'race@example.com',
'email_verified' => true,
'name' => 'Race User',
])->map([
'id' => 'oidc-race-id',
'name' => 'Race User',
'email' => 'race@example.com',
]), OauthSetting::where('provider', 'oidc')->firstOrFail());
} finally {
Event::forget($eventName);
}
expect($resolvedUser->is($user))->toBeTrue()
->and(OauthIdentity::where('provider_user_id', 'oidc-race-id')->count())->toBe(1);
$this->assertAuthenticatedAs($user);
});
it('creates a new oidc user when provider registration is allowed while normal registration is disabled', function () {
OauthSetting::where('provider', 'oidc')->update(['allow_registration' => true]);
fakeOidcProvider(['email' => 'newuser@example.com']);
$response = $this->get(route('auth.callback', 'oidc'));
$response->assertRedirect('/');
$user = User::whereEmail('newuser@example.com')->first();
expect($user)->not->toBeNull()
->and($user->password)->not->toBeNull();
$this->assertAuthenticatedAs($user);
$this->assertDatabaseHas('oauth_identities', [
'user_id' => $user->id,
'provider' => 'oidc',
'issuer' => 'https://idp.example.com',
'provider_user_id' => 'okta-user-1',
]);
});
it('creates a new oidc user in the root team only when provider root auto-join is enabled', function () {
Team::forceCreate(['id' => 0, 'name' => 'Root Team', 'personal_team' => true]);
(new User)->forceFill([
'id' => 0,
'name' => 'Root User',
'email' => 'root@example.com',
'password' => 'password',
])->save();
OauthSetting::where('provider', 'oidc')->update([
'allow_registration' => true,
'auto_join_root_team' => true,
]);
fakeOidcProvider(['email' => 'root-member@example.com', 'name' => 'Root Member']);
$response = $this->get(route('auth.callback', 'oidc'));
$response->assertRedirect('/');
$user = User::whereEmail('root-member@example.com')->first();
expect($user)->not->toBeNull()
->and($user->teams()->count())->toBe(1);
$rootMembership = $user->teams()->where('teams.id', 0)->first();
expect($rootMembership)->not->toBeNull()
->and($rootMembership->pivot->role)->toBe('member');
$this->assertDatabaseMissing('teams', [
'name' => "Root Member's Team",
]);
expect(session('currentTeam')->id)->toBe(0);
$this->assertAuthenticatedAs($user);
});
it('rejects linking an unverified oidc email to an existing local account', function () {
$user = User::factory()->create(['email' => 'victim@example.com']);
fakeOidcProvider(['email' => 'victim@example.com', 'email_verified' => false]);
$response = $this->from('/login')->get(route('auth.callback', 'oidc'));
$response->assertRedirect('/login');
$this->assertGuest();
$this->assertDatabaseMissing('oauth_identities', [
'user_id' => $user->id,
'provider' => 'oidc',
]);
});
it('rejects new oidc users when neither normal nor provider registration is enabled', function () {
fakeOidcProvider(['email' => 'blocked@example.com']);
$response = $this->from('/login')->get(route('auth.callback', 'oidc'));
$response->assertRedirect('/login');
expect(User::whereEmail('blocked@example.com')->exists())->toBeFalse();
});
it('creates the root user when oidc provisions the first account', function () {
Team::forceCreate(['id' => 0, 'name' => 'Root Team', 'personal_team' => true]);
OauthSetting::where('provider', 'oidc')->update(['allow_registration' => true]);
fakeOidcProvider(['email' => 'root@example.com', 'name' => 'Root User']);
$response = $this->get(route('auth.callback', 'oidc'));
$response->assertRedirect('/');
$this->assertDatabaseHas('users', ['id' => 0, 'email' => 'root@example.com']);
$this->assertDatabaseHas('team_user', ['team_id' => 0, 'user_id' => 0, 'role' => 'owner']);
expect(InstanceSettings::find(0)->is_registration_enabled)->toBeFalse();
});
it('persists raw claims as an array on the oauth identity', function () {
OauthSetting::where('provider', 'oidc')->update(['allow_registration' => true]);
fakeOidcProvider(['email' => 'claims@example.com']);
$this->get(route('auth.callback', 'oidc'))->assertRedirect('/');
$identity = OauthIdentity::where('email', 'claims@example.com')->first();
expect($identity->raw_claims)->toBeArray()
->and($identity->raw_claims['sub'])->toBe('okta-user-1');
});
it('stores empty raw claims when the provider returns no user payload', function () {
OauthSetting::where('provider', 'oidc')->update(['allow_registration' => true]);
$user = (new OidcUser)->setIdTokenClaims([
'iss' => 'https://idp.example.com',
'sub' => 'okta-no-payload',
'email_verified' => true,
])->map([
'id' => 'okta-no-payload',
'name' => 'No Payload',
'email' => 'nopayload@example.com',
]);
$user->user = null;
$provider = Mockery::mock();
$provider->shouldReceive('setConfig')->andReturnSelf();
$provider->shouldReceive('user')->andReturn($user);
Socialite::shouldReceive('driver')->with('oidc')->andReturn($provider);
$this->get(route('auth.callback', 'oidc'))->assertRedirect('/');
$identity = OauthIdentity::where('email', 'nopayload@example.com')->first();
expect($identity->raw_claims)->toBe([]);
});
it('rejects callbacks for disabled oidc provider', function () {
OauthSetting::where('provider', 'oidc')->update(['enabled' => false]);
$response = $this->from('/login')->get(route('auth.callback', 'oidc'));
$response->assertRedirect('/login');
});
it('logs callback failures with diagnostic context', function () {
Log::spy();
$provider = Mockery::mock();
$provider->shouldReceive('setConfig')->andReturnSelf();
$provider->shouldReceive('user')->andThrow(new RuntimeException('Token exchange failed'));
Socialite::shouldReceive('driver')->with('oidc')->andReturn($provider);
$response = $this->from('/login')->get(route('auth.callback', ['provider' => 'oidc', 'code' => 'secret-code', 'state' => 'state-value']));
$response->assertRedirect('/login');
Log::shouldHaveReceived('error')->once()->withArgs(function (string $message, array $context) {
return $message === 'OAuth callback failed.'
&& $context['provider'] === 'oidc'
&& $context['exception_class'] === RuntimeException::class
&& $context['exception_message'] === 'Token exchange failed'
&& $context['has_code'] === true
&& $context['has_state'] === true
&& $context['exception'] instanceof RuntimeException;
});
});
@@ -33,6 +33,7 @@ it('keeps storage backup schedule tables horizontally scrollable on mobile', fun
->and($css)->toMatch('/\.backup-table-grid\s*\{[^}]*min-width:\s*50rem;/');
});
use App\Livewire\Project\Service\Storage;
use App\Livewire\Project\Service\VolumeBackup\Create as CreateServiceVolumeBackup;
use App\Livewire\Project\Shared\Storages\All;
use App\Models\Application;
@@ -183,7 +184,7 @@ it('renders volumes as a data table with shared column headers', function () {
->toMatch('/<x-callout[^>]*title="File-level consistency"[\s\S]*id="stopDuringBackup"[\s\S]*<\/x-callout>/');
expect(file_get_contents(resource_path('views/livewire/project/shared/storages/volume-backups/executions.blade.php')))
->toContain('<span>Time</span>')
->toContain('x-forms.copy-button')
->toContain('x-forms.copy-input')
->toContain('col-span-6');
$css = file_get_contents(resource_path('css/app.css'));
@@ -206,6 +207,62 @@ it('renders volumes as a data table with shared column headers', function () {
->toMatch('/\.application-settings-form label\s*\{[^}]*font-size:\s*13px/s');
});
it('keeps bind mount source paths out of the add volume form', function () {
$storageView = file_get_contents(resource_path('views/livewire/project/service/storage.blade.php'));
$volumesView = file_get_contents(resource_path('views/livewire/project/shared/storages/all.blade.php'));
expect($storageView)
->not->toContain('id="host_path"')
->not->toContain('Swarm Mode detected')
->and($volumesView)
->toMatch('/<x-modal-confirmation title="Remove Source Path\?"[^>]*canGate="update"[^>]*:canResource="\$resource"/')
->toContain('The next deployment will use a named Docker volume instead.')
->toContain('Data from the existing host directory will not be copied to the named volume.');
});
it('creates named volumes without a host path in swarm mode', function () {
[$application] = createApplicationWithVolume();
$application->persistentStorages()->delete();
Livewire::test(Storage::class, ['resource' => $application])
->set('isSwarm', true)
->set('name', 'storage-app-data')
->set('mount_path', '/data')
->call('submitPersistentVolume')
->assertHasNoErrors();
expect($application->persistentStorages()->first())
->name->toBe($application->uuid.'-storage-app-data')
->host_path->toBeNull();
});
it('uses a resource based default name for new volumes', function () {
[$application] = createApplicationWithVolume(['name' => 'Storage App']);
Livewire::test(Storage::class, ['resource' => $application])
->assertSet('name', 'storage-app-data');
});
it('uses a valid fallback default volume name when the resource name has no slug characters', function () {
[$application] = createApplicationWithVolume(['name' => '---']);
Livewire::test(Storage::class, ['resource' => $application])
->assertSet('name', 'volume-data');
});
it('removes existing bind mount source paths from the volume table', function () {
[$application, $volume] = createApplicationWithVolume(volumeAttributes: [
'host_path' => '/srv/storage',
]);
Livewire::test(All::class, ['resource' => $application])
->assertSet("forms.{$volume->id}.hostPath", '/srv/storage')
->call('clearHostPath', $volume->id)
->assertHasNoErrors();
expect($volume->refresh()->host_path)->toBeNull();
});
it('creates and exposes volume backups for service storage', function () {
$service = Service::factory()->create([
'environment_id' => $this->environment->id,
+91
View File
@@ -0,0 +1,91 @@
<?php
use App\Livewire\Profile\Index as ProfileIndex;
use App\Models\OauthIdentity;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('shows when the profile user signed in with sso', function () {
$user = User::factory()->create(['name' => 'Profile User']);
OauthIdentity::create([
'user_id' => $user->id,
'provider' => 'oidc',
'issuer' => 'https://idp.example.com',
'provider_user_id' => 'idp-user-1',
'email' => $user->email,
]);
$this->actingAs($user);
Livewire::test(ProfileIndex::class)
->assertSee('Signed in with SSO')
->assertSee('OIDC');
});
it('does not show sso status for password-only profile users', function () {
$user = User::factory()->create(['name' => 'Profile User']);
$this->actingAs($user);
Livewire::test(ProfileIndex::class)
->assertDontSee('Signed in with SSO');
});
it('prevents sso linked users from opening or requesting profile email changes', function () {
$user = User::factory()->create(['name' => 'SSO User', 'email' => 'sso@example.com']);
OauthIdentity::create([
'user_id' => $user->id,
'provider' => 'oidc',
'issuer' => 'https://idp.example.com',
'provider_user_id' => 'idp-user-1',
'email' => $user->email,
]);
$this->actingAs($user);
Livewire::test(ProfileIndex::class)
->assertSee('Email is managed by your SSO provider.')
->call('showEmailChangeForm')
->assertSet('show_email_change', false)
->assertDispatched('error')
->set('new_email', 'changed@example.com')
->call('requestEmailChange')
->assertSet('show_email_change', false)
->assertSet('show_verification', false)
->assertDispatched('error');
$user->refresh();
expect($user->email)->toBe('sso@example.com')
->and($user->pending_email)->toBeNull()
->and($user->email_change_code)->toBeNull()
->and($user->email_change_code_expires_at)->toBeNull();
});
it('keeps profile email changes available for password-only users', function () {
config()->set('constants.coolify.self_hosted', false);
Notification::fake();
$user = User::factory()->create(['name' => 'Password User', 'email' => 'password@example.com']);
$this->actingAs($user);
Livewire::test(ProfileIndex::class)
->call('showEmailChangeForm')
->assertSet('show_email_change', true)
->set('new_email', 'changed@example.com')
->call('requestEmailChange')
->assertSet('show_verification', true)
->assertDispatched('success');
$user->refresh();
expect($user->pending_email)->toBe('changed@example.com')
->and($user->email_change_code)->not->toBeNull();
});
@@ -27,31 +27,29 @@ it('keeps the resource details helper text visible below the modal header', func
])->render();
expect($html)
->toContain('Identifiers for this resource. Read-only')
->toContain('readonly')
->toContain('pt-1')
->not->toContain('-mt-4');
});
it('renders copy fields as visible readonly controls with an accessible copy action', function () {
$html = Blade::render('<x-forms.copy-button label="UUID" text="crashloop" />');
$html = Blade::render('<x-forms.copy-input label="UUID" text="crashloop" />');
expect($html)
->toContain('label class="flex gap-1 items-center mb-1 text-sm font-medium text-black dark:text-white"')
->toContain('readonly')
->toContain('window.copyToClipboard')
->toContain('x-data="copyButton"')
->toContain('input-with-copy-button')
->toContain('copy-button')
->toContain('aria-label="Copy to clipboard"')
->toContain('title="Copy to clipboard"')
->toContain('class="size-[18px] text-green-500"');
->toContain('title="Copy to clipboard"');
});
it('uses the shared copy field for newly issued api tokens', function () {
it('uses the shared copy button for newly issued api tokens', function () {
$blade = file_get_contents(resource_path('views/livewire/security/api-tokens.blade.php'));
expect($blade)
->toContain('<x-forms.copy-button :text="session(\'token\')" />')
->not->toContain('navigator.clipboard.writeText(@js(session(\'token\')))');
->toContain('<x-copy-button :value="session(\'token\')"')
->not->toContain('navigator.clipboard');
});
it('keeps copy button padding above settings-workspace input overrides', function () {
@@ -0,0 +1,253 @@
<?php
use App\Livewire\Security\IntegrationTokenEditor;
use App\Livewire\Security\IntegrationTokenForm;
use App\Livewire\Security\IntegrationTokens;
use App\Models\InstanceSettings;
use App\Models\IntegrationToken;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Once;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
if (! InstanceSettings::query()->whereKey(0)->exists()) {
$settings = new InstanceSettings;
$settings->id = 0;
$settings->save();
}
Once::flush();
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->actingAs($this->user);
});
test('a cloudflare dns token is validated with read only requests before it is saved', function () {
Http::fake([
'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response([
'success' => true,
'result' => ['status' => 'active'],
]),
'https://api.cloudflare.com/client/v4/zones?per_page=1' => Http::response([
'success' => true,
'result' => [['id' => 'zone-id']],
]),
'https://api.cloudflare.com/client/v4/zones/zone-id/dns_records?per_page=1' => Http::response([
'success' => true,
'result' => [],
]),
]);
Livewire::test(IntegrationTokenForm::class, ['modal_mode' => true])
->set('provider', 'cloudflare')
->set('name', 'Production DNS')
->set('token', 'cloudflare-token')
->set('capabilities', ['dns'])
->call('addToken')
->assertHasNoErrors()
->assertDispatched('close-modal');
$this->assertDatabaseHas('integration_tokens', [
'team_id' => $this->team->id,
'provider' => 'cloudflare',
'name' => 'Production DNS',
]);
Http::assertSentCount(3);
Http::assertSent(fn ($request) => $request->method() === 'GET'
&& $request->url() === 'https://api.cloudflare.com/client/v4/zones/zone-id/dns_records?per_page=1');
});
test('a cloudflare token is not saved when scope validation fails', function () {
Http::fake([
'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response([
'success' => true,
'result' => ['status' => 'active'],
]),
'https://api.cloudflare.com/client/v4/zones?per_page=1' => Http::response([
'success' => false,
'errors' => [['message' => 'Authentication error']],
], 403),
]);
Livewire::test(IntegrationTokenForm::class)
->set('name', 'Invalid DNS token')
->set('token', 'cloudflare-token')
->set('capabilities', ['dns'])
->call('addToken')
->assertDispatched('error');
$this->assertDatabaseCount('integration_tokens', 0);
});
test('at least one capability is required when adding a cloudflare token', function () {
Livewire::test(IntegrationTokenForm::class)
->set('name', 'Account token')
->set('token', 'cloudflare-token')
->set('capabilities', [])
->call('addToken')
->assertHasErrors(['capabilities' => 'required']);
$this->assertDatabaseCount('integration_tokens', 0);
Http::assertNothingSent();
});
test('integration tokens page lists saved provider and capabilities', function () {
IntegrationToken::query()->create([
'team_id' => $this->team->id,
'provider' => 'cloudflare',
'name' => 'Production DNS',
'token' => 'secret',
'capabilities' => ['dns'],
]);
Livewire::test(IntegrationTokens::class)
->assertSee('Production DNS')
->assertSee('Cloudflare')
->assertSee('DNS');
});
test('cloudflare dns scope guidance and token creation link are shown', function () {
Livewire::test(IntegrationTokenForm::class)
->set('capabilities', ['dns'])
->assertSee('Zone - DNS - Edit')
->assertSee('Zone - Zone - Read')
->assertSeeHtml('https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22edit%22%7D%5D&amp;accountId=%2A&amp;zoneId=all&amp;name=Coolify%20DNS%20Management');
expect(file_get_contents(resource_path('views/livewire/security/integration-token-form.blade.php')))
->toContain('permissionGroupKeys=%5B%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22edit%22%7D%5D');
});
test('capability selection uses the shared checkbox component', function () {
$view = file_get_contents(resource_path('views/livewire/security/integration-token-form.blade.php'));
expect($view)
->toContain('<x-forms.checkbox')
->toContain('class="mt-3 rounded-lg border')
->not->toContain('<input type="checkbox"');
});
test('submit button uses the shared highlighted loading state', function () {
$view = file_get_contents(resource_path('views/livewire/security/integration-token-form.blade.php'));
expect($view)
->toContain('wire:target="addToken" isHighlighted')
->not->toContain('class="button-highlighted"');
});
test('saved integration token rows render modal editors with a gear button', function () {
IntegrationToken::query()->create([
'team_id' => $this->team->id,
'provider' => 'cloudflare',
'name' => 'Production DNS',
'token' => 'original-token',
'capabilities' => ['dns'],
]);
Livewire::test(IntegrationTokens::class)
->assertSee('Edit Integration Token')
->assertSee('Production DNS')
->assertSeeHtml(':aria-label="`Edit ${tokenName}`"');
});
test('an integration token can be rotated after validating its capabilities', function () {
Http::fake([
'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response([
'success' => true,
'result' => ['status' => 'active'],
]),
'https://api.cloudflare.com/client/v4/zones?per_page=1' => Http::response([
'success' => true,
'result' => [['id' => 'zone-id']],
]),
'https://api.cloudflare.com/client/v4/zones/zone-id/dns_records?per_page=1' => Http::response([
'success' => true,
'result' => [],
]),
]);
$savedToken = IntegrationToken::query()->create([
'team_id' => $this->team->id,
'provider' => 'cloudflare',
'name' => 'Production DNS',
'token' => 'original-token',
'capabilities' => ['dns'],
]);
Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid])
->set('name', 'Rotated DNS')
->set('newToken', 'rotated-token')
->call('save')
->assertHasNoErrors()
->assertDispatched('success');
$savedToken->refresh();
expect($savedToken->name)->toBe('Rotated DNS')
->and($savedToken->token)->toBe('rotated-token');
});
test('leaving the token field blank keeps the existing integration token', function () {
Http::fake();
$savedToken = IntegrationToken::query()->create([
'team_id' => $this->team->id,
'provider' => 'cloudflare',
'name' => 'Production DNS',
'token' => 'original-token',
'capabilities' => ['dns'],
]);
Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid])
->set('name', 'Renamed DNS')
->set('newToken', '')
->call('save')
->assertHasNoErrors();
$savedToken->refresh();
expect($savedToken->name)->toBe('Renamed DNS')
->and($savedToken->token)->toBe('original-token');
Http::assertNothingSent();
});
test('an invalid replacement does not rotate the integration token', function () {
Http::fake([
'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response([
'success' => false,
], 403),
]);
$savedToken = IntegrationToken::query()->create([
'team_id' => $this->team->id,
'provider' => 'cloudflare',
'name' => 'Production DNS',
'token' => 'original-token',
'capabilities' => ['dns'],
]);
Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid])
->set('newToken', 'invalid-token')
->call('save')
->assertDispatched('error');
expect($savedToken->fresh()->token)->toBe('original-token');
});
test('editor updates its row without rerendering the teleported parent modal', function () {
$component = file_get_contents(app_path('Livewire/Security/IntegrationTokenEditor.php'));
expect($component)
->toContain("'integration-token-updated'")
->toContain("'integration-token-deleted'")
->not->toContain('integrationTokenChanged');
});
@@ -8,6 +8,7 @@ it('uses shared sidebar navigation for keys and tokens pages', function () {
'security/private-key/index.blade.php',
'security/private-key/show.blade.php',
'security/cloud-tokens.blade.php',
'security/integration-tokens.blade.php',
'security/cloud-provider-token/show.blade.php',
'security/cloud-init-scripts.blade.php',
'security/cloud-init-script/show.blade.php',
@@ -22,6 +23,7 @@ it('uses shared sidebar navigation for keys and tokens pages', function () {
->toContain('application-settings-navigation')
->toContain("'label' => 'Private Keys'")
->toContain("'label' => 'Cloud Tokens'")
->toContain("'label' => 'Integration Tokens'")
->toContain("'label' => 'Cloud-Init Scripts'")
->toContain("'label' => 'API Tokens'");
@@ -0,0 +1,64 @@
<?php
use App\Livewire\SettingsEmail;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->settings = new InstanceSettings;
$this->settings->id = 0;
$this->settings->save();
$this->rootTeam = Team::factory()->create(['id' => 0]);
$this->user = User::factory()->create();
$this->user->teams()->attach($this->rootTeam, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->rootTeam]);
});
test('enabling SMTP disables Resend in storage', function () {
$this->settings->update([
'resend_enabled' => true,
'resend_api_key' => 're_test_key',
'smtp_from_address' => 'from@example.com',
'smtp_from_name' => 'Coolify',
]);
Livewire::test(SettingsEmail::class)
->set('smtpHost', 'smtp.example.com')
->set('smtpPort', '587')
->set('smtpEncryption', 'starttls')
->set('smtpFromAddress', 'from@example.com')
->set('smtpFromName', 'Coolify')
->call('toggleSmtp');
$this->settings->refresh();
expect($this->settings->smtp_enabled)->toBeTrue();
expect($this->settings->resend_enabled)->toBeFalse();
});
test('enabling Resend disables SMTP in storage', function () {
$this->settings->update([
'smtp_enabled' => true,
'smtp_host' => 'smtp.example.com',
'smtp_port' => '587',
'smtp_encryption' => 'starttls',
'smtp_from_address' => 'from@example.com',
'smtp_from_name' => 'Coolify',
]);
Livewire::test(SettingsEmail::class)
->set('resendApiKey', 're_test_key')
->set('smtpFromAddress', 'from@example.com')
->set('smtpFromName', 'Coolify')
->call('toggleResend');
$this->settings->refresh();
expect($this->settings->resend_enabled)->toBeTrue();
expect($this->settings->smtp_enabled)->toBeFalse();
});
+52
View File
@@ -0,0 +1,52 @@
<?php
it('keeps backup and transactional email out of the settings top navigation', function () {
$this->blade('<x-settings.navbar />')
->assertSeeText('Configuration')
->assertSeeText('OAuth')
->assertSeeText('Scheduled Jobs')
->assertDontSeeText('Instance Backup')
->assertDontSeeText('Transactional Email');
});
it('shows backup and transactional email in the settings configuration sidebar', function () {
$view = $this->blade('<x-settings.sidebar activeMenu="backup" />')
->assertSeeTextInOrder([
'General',
'Advanced',
'Instance Backup',
'Transactional Email',
'Updates',
]);
expect((string) $view)
->toContain(route('settings.backup'))
->toContain(route('settings.email'))
->and(substr_count((string) $view, 'menu-item-active'))->toBe(1);
});
it('renders backup and transactional email pages with the settings configuration sidebar', function () {
expect(file_get_contents(resource_path('views/livewire/settings-backup.blade.php')))
->toContain('<x-settings.sidebar activeMenu="backup" />')
->and(file_get_contents(resource_path('views/livewire/settings-email.blade.php')))
->toContain('<x-settings.sidebar activeMenu="email" />');
});
it('uses the same title and description spacing on backup and transactional email settings pages', function () {
expect(file_get_contents(resource_path('views/livewire/settings-backup.blade.php')))
->not->toContain('class="flex items-center gap-2 pb-2"')
->toContain('<div class="pb-4">Instance backup configuration for Coolify instance.</div>')
->and(file_get_contents(resource_path('views/livewire/settings-email.blade.php')))
->not->toContain('class="flex flex-col gap-2 pb-4"')
->toContain('<div class="pb-4">Instance wide email settings for password resets, invitations, etc.</div>');
});
it('uses instance backup as the backup settings label', function () {
expect(file_get_contents(resource_path('views/components/settings/sidebar.blade.php')))
->toContain('<span class="menu-item-label">Instance Backup</span>')
->not->toContain('<span class="menu-item-label">Backup</span>')
->and(file_get_contents(resource_path('views/livewire/settings-backup.blade.php')))
->toContain('<h2>Instance Backup</h2>')
->toContain('Instance backup configuration for Coolify instance.')
->not->toContain('<h2>Backup</h2>');
});
+277
View File
@@ -0,0 +1,277 @@
<?php
use App\Http\Middleware\DecideWhatToDoWithUser;
use App\Livewire\SettingsOauth;
use App\Models\InstanceSettings;
use App\Models\OauthSetting;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Once;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function actingAsInstanceAdmin(): User
{
$team = Team::forceCreate(['id' => 0, 'name' => 'Root Team', 'personal_team' => true]);
$user = User::factory()->create(['id' => 0, 'email' => 'root@example.com', 'email_verified_at' => now()]);
if (! $user->teams()->whereKey($team->id)->exists()) {
$user->teams()->attach($team, ['role' => 'owner']);
}
session(['currentTeam' => $team]);
test()->actingAs($user);
return $user;
}
beforeEach(function () {
$this->withoutVite();
config()->set('app.maintenance.driver', 'file');
InstanceSettings::forceCreate(['id' => 0, 'is_registration_enabled' => true]);
Once::flush();
OauthSetting::create(['provider' => 'oidc']);
OauthSetting::create(['provider' => 'authentik']);
OauthSetting::create(['provider' => 'bitbucket']);
});
it('uses the standard settings design and keeps every oauth provider on one page', function () {
actingAsInstanceAdmin();
$this->withoutMiddleware(DecideWhatToDoWithUser::class)
->get(route('settings.oauth'))
->assertSuccessful()
->assertSee('Authentication')
->assertSee('Registration')
->assertSee('Authentik')
->assertSee('Bitbucket')
->assertSee('OpenID Connect')
->assertSee('Disable password registration when OAuth is enabled')
->assertSee('Client secret')
->assertSee('application-settings-form', false)
->assertDontSee(route('settings.oauth.provider', 'authentik'), false);
});
it('lists openid connect before the other oauth providers', function () {
actingAsInstanceAdmin();
$providers = array_keys(Livewire::test(SettingsOauth::class)->get('oauth_settings_map'));
expect($providers[0])->toBe('oidc');
});
it('has an icon for openid connect', function () {
expect(public_path('svgs/oidc.svg'))->toBeFile();
});
it('auto saves registration policy without a general save button', function () {
actingAsInstanceAdmin();
$this->withoutMiddleware(DecideWhatToDoWithUser::class)
->get(route('settings.oauth'))
->assertSuccessful()
->assertSee("wire:click='saveRegistrationPolicy'", false)
->assertDontSee('Save</button>', false);
Livewire::test(SettingsOauth::class)
->set('disable_registration_when_oauth_enabled', true)
->call('saveRegistrationPolicy')
->assertHasNoErrors()
->assertDispatched('success');
expect(instanceSettings()->fresh()->disable_registration_when_oauth_enabled)->toBeTrue();
});
it('shows oidc fields with a naked okta issuer url example', function () {
actingAsInstanceAdmin();
$this->withoutMiddleware(DecideWhatToDoWithUser::class)
->get(route('settings.oauth'))
->assertSuccessful()
->assertSee('OpenID Connect')
->assertSee('https://example.okta.com', false)
->assertDontSee('/oauth2/default', false);
});
it('groups oidc fields in the expected desktop order', function () {
$view = file_get_contents(resource_path('views/livewire/settings-oauth.blade.php'));
$fields = [
'redirect_uri',
'base_url',
'client_id',
'client_secret',
'scopes',
'clock_skew_seconds',
'custom_label',
];
$positions = array_map(
fn (string $field): int|false => strpos($view, "id=\"oauth_settings_map.{{ \$provider }}.$field\""),
$fields,
);
expect($positions)->not->toContain(false)
->and($positions)->toBe(collect($positions)->sort()->values()->all())
->and($view)->toContain('<div class="lg:col-span-2">');
});
it('shows provider enable controls as settings section actions', function () {
actingAsInstanceAdmin();
$this->withoutMiddleware(DecideWhatToDoWithUser::class)
->get(route('settings.oauth'))
->assertSuccessful()
->assertSee('Enable')
->assertDontSee('label="Enabled"', false)
->assertDontSee('p-4 border dark:border-coolgray-300 border-neutral-200', false);
});
it('stacks oidc option checkboxes vertically', function () {
actingAsInstanceAdmin();
$this->withoutMiddleware(DecideWhatToDoWithUser::class)
->get(route('settings.oauth'))
->assertSuccessful()
->assertSee('Allow OIDC user creation')
->assertSee('Require verified email')
->assertSee('Use PKCE')
->assertDontSee('flex flex-col gap-2 pt-2 md:flex-row', false);
});
it('does not show unknown oauth providers', function () {
actingAsInstanceAdmin();
$this->withoutMiddleware(DecideWhatToDoWithUser::class)
->get('/settings/oauth/unknown')
->assertNotFound();
});
it('defaults oidc user creation and verified email requirement to enabled', function () {
$setting = OauthSetting::where('provider', 'oidc')->first();
expect($setting->allow_registration)->toBeTrue()
->and($setting->require_email_verified)->toBeTrue()
->and($setting->auto_join_root_team)->toBeFalse();
});
it('persists oidc oauth settings from livewire', function () {
actingAsInstanceAdmin();
Livewire::test(SettingsOauth::class)
->set('oauth_settings_map.oidc.enabled', true)
->set('oauth_settings_map.oidc.client_id', 'client-id')
->set('oauth_settings_map.oidc.client_secret', 'secret')
->set('oauth_settings_map.oidc.redirect_uri', 'https://coolify.example.com/auth/oidc/callback')
->set('oauth_settings_map.oidc.base_url', 'https://idp.example.com')
->set('oauth_settings_map.oidc.scopes', 'openid email profile groups')
->set('oauth_settings_map.oidc.custom_label', 'Login with Okta')
->set('oauth_settings_map.oidc.allow_registration', true)
->set('oauth_settings_map.oidc.auto_join_root_team', true)
->set('oauth_settings_map.oidc.require_email_verified', true)
->set('disable_registration_when_oauth_enabled', true)
->call('submit')
->assertHasNoErrors();
$setting = OauthSetting::where('provider', 'oidc')->first();
expect($setting->enabled)->toBeTrue()
->and($setting->redirect_uri)->toBe('https://coolify.example.com/auth/oidc/callback')
->and($setting->base_url)->toBe('https://idp.example.com')
->and($setting->custom_label)->toBe('Login with Okta')
->and($setting->scopeList())->toBe(['openid', 'email', 'profile', 'groups'])
->and($setting->allow_registration)->toBeTrue()
->and($setting->auto_join_root_team)->toBeTrue();
expect(instanceSettings()->fresh()->disable_registration_when_oauth_enabled)->toBeTrue();
});
it('saves only the selected provider from provider pages', function () {
actingAsInstanceAdmin();
Livewire::test(SettingsOauth::class, ['provider' => 'authentik'])
->set('oauth_settings_map.oidc.redirect_uri', 'not-a-url')
->set('oauth_settings_map.authentik.enabled', true)
->set('oauth_settings_map.authentik.client_id', 'authentik-client')
->set('oauth_settings_map.authentik.client_secret', 'authentik-secret')
->set('oauth_settings_map.authentik.base_url', 'https://authentik.example.com')
->call('submit')
->assertHasNoErrors();
$setting = OauthSetting::where('provider', 'authentik')->first();
expect($setting->enabled)->toBeTrue()
->and($setting->client_id)->toBe('authentik-client')
->and($setting->base_url)->toBe('https://authentik.example.com');
});
it('validates oidc url fields before saving', function (string $field, string $value) {
actingAsInstanceAdmin();
Livewire::test(SettingsOauth::class)
->set('oauth_settings_map.oidc.client_id', 'client-id')
->set('oauth_settings_map.oidc.client_secret', 'secret')
->set('oauth_settings_map.oidc.base_url', 'https://idp.example.com')
->set("oauth_settings_map.oidc.$field", $value)
->call('submit')
->assertHasErrors(["oauth_settings_map.oidc.$field" => 'url']);
$setting = OauthSetting::where('provider', 'oidc')->first();
expect($setting->{$field})->toBeNull();
})->with([
'invalid redirect uri' => ['redirect_uri', 'not-a-url'],
'non-http redirect uri' => ['redirect_uri', 'javascript:alert(1)'],
'invalid issuer url' => ['base_url', 'not-a-url'],
'non-http issuer url' => ['base_url', 'ftp://idp.example.com'],
]);
it('does not enable oidc without required fields', function () {
actingAsInstanceAdmin();
Livewire::test(SettingsOauth::class)
->set('oauth_settings_map.oidc.enabled', true)
->call('instantSave', 'oidc')
->assertDispatched('error');
expect(OauthSetting::where('provider', 'oidc')->first()->enabled)->toBeFalse();
});
it('keeps provider disabled in the ui when enable validation fails', function () {
actingAsInstanceAdmin();
Livewire::test(SettingsOauth::class, ['provider' => 'authentik'])
->call('toggleProvider', 'authentik')
->assertDispatched('error')
->assertSet('oauth_settings_map.authentik.enabled', false);
expect(OauthSetting::where('provider', 'authentik')->first()->enabled)->toBeFalse();
});
it('disables an enabled provider gracefully when required fields become incomplete', function () {
actingAsInstanceAdmin();
OauthSetting::where('provider', 'authentik')->first()->forceFill([
'enabled' => true,
'client_id' => 'authentik-client',
'client_secret' => 'authentik-secret',
'base_url' => 'https://authentik.example.com',
])->save();
Livewire::test(SettingsOauth::class, ['provider' => 'authentik'])
->set('oauth_settings_map.authentik.client_secret', '')
->call('submit')
->assertDispatched('error')
->assertSet('oauth_settings_map.authentik.enabled', false);
expect(OauthSetting::where('provider', 'authentik')->first()->enabled)->toBeFalse();
});
it('toggles provider enabled state from the action button', function () {
actingAsInstanceAdmin();
Livewire::test(SettingsOauth::class, ['provider' => 'authentik'])
->set('oauth_settings_map.authentik.client_id', 'authentik-client')
->set('oauth_settings_map.authentik.client_secret', 'authentik-secret')
->set('oauth_settings_map.authentik.base_url', 'https://authentik.example.com')
->call('toggleProvider', 'authentik')
->assertHasNoErrors();
expect(OauthSetting::where('provider', 'authentik')->first()->enabled)->toBeTrue();
});
+1 -1
View File
@@ -153,7 +153,7 @@ it('adds mux options to ssh commands only after the explicit master is ready', f
->toContain('-o ControlMaster=auto')
->toContain("-o ControlPath=/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}")
->toContain('-o ControlPersist=3600')
->toContain("'bash -se' << \\")
->toContain("'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi' << \\")
->not->toContain('<< $delimiter');
Process::assertRan(fn ($process) => str_contains($process->command, 'ssh -fN '));
+4 -10
View File
@@ -51,27 +51,21 @@ it('renders a real copy button for pending invitation links', function () {
$view = file_get_contents(resource_path('views/livewire/team/invitations.blade.php'));
expect($view)
->toContain('aria-label="Copy invitation link"')
->toContain('window.copyToClipboard(@js($invite->link))')
->toContain('class="button h-7! shrink-0 px-2!"');
->toContain('<x-copy-button :value="$invite->link" label="Copy invitation link" />');
Livewire::test(Invitations::class, [
'invitations' => TeamInvitation::ownedByCurrentTeam()->get(),
])
->assertSee($invitation->link)
->assertSeeHtml('aria-label="Copy invitation link"')
->assertSeeHtml('window.copyToClipboard(')
->assertSeeHtml('x-data="copyButton"')
->assertSeeHtml('type="button"');
});
it('exposes a resilient global copyToClipboard helper', function () {
it('keeps clipboard logic in the shared copy button instead of a global helper', function () {
$layout = file_get_contents(resource_path('views/layouts/base.blade.php'));
expect($layout)
->toContain('async function copyToClipboard(text)')
->toContain('window.copyToClipboard = copyToClipboard')
->toContain('document.execCommand(\'copy\')')
->toContain('window.isSecureContext');
expect($layout)->not->toContain('copyToClipboard');
});
it('preserves a provisional user when revoking their invitation fails', function () {
+16
View File
@@ -0,0 +1,16 @@
<?php
use App\Models\User;
use Database\Seeders\UserSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('leaves the user id sequence ready for new development users', function () {
$this->seed(UserSeeder::class);
$user = User::factory()->create();
expect(User::query()->orderBy('id')->pluck('id')->all())->toBe([0, 1, 2, 3])
->and($user->id)->toBe(3);
});
@@ -0,0 +1,62 @@
<?php
use App\Actions\Server\CheckUpdates;
use App\Actions\Server\InstallDocker;
use App\Actions\Server\InstallPrerequisites;
it('installs Bash while bootstrapping Alpine prerequisites', function () {
$method = new ReflectionMethod(InstallPrerequisites::class, 'getAlpinePrerequisiteCommands');
$commands = $method->invoke(new InstallPrerequisites);
expect($commands)->toContain('command -v bash >/dev/null || apk add bash');
});
it('installs every Docker CLI plugin required on Alpine', function () {
$method = new ReflectionMethod(InstallDocker::class, 'getAlpineDockerInstallCommand');
$command = $method->invoke(new InstallDocker);
expect($command)->toContain('apk add docker docker-cli-buildx docker-cli-compose');
});
it('uses OpenRC instead of systemd to restart Docker on Alpine', function () {
$method = new ReflectionMethod(InstallDocker::class, 'getDockerServiceCommands');
$action = new InstallDocker;
$commands = $method->invoke($action, true);
expect($commands)
->toBe(['rc-update add docker default', 'rc-service docker restart'])
->each->not->toContain('systemctl')
->and($method->invoke($action, false))
->toBe(['systemctl enable docker >/dev/null 2>&1 || true', 'systemctl restart docker']);
});
it('parses Alpine package updates', function () {
$method = new ReflectionMethod(CheckUpdates::class, 'parseApkOutput');
$output = <<<'OUTPUT'
docker-cli-compose-2.31.0-r5 x86_64 {docker-cli-compose} (Apache-2.0) [upgradable from: docker-cli-compose-2.31.0-r4]
libcrypto3-3.3.4-r0 aarch64 {openssl} (Apache-2.0) [upgradable from: libcrypto3-3.3.3-r0]
OUTPUT;
$result = $method->invoke(new CheckUpdates, $output);
expect($result)->toBe([
'total_updates' => 2,
'updates' => [
[
'package' => 'docker-cli-compose',
'new_version' => '2.31.0-r5',
'architecture' => 'x86_64',
'current_version' => '2.31.0-r4',
],
[
'package' => 'libcrypto3',
'new_version' => '3.3.4-r0',
'architecture' => 'aarch64',
'current_version' => '3.3.3-r0',
],
],
]);
});
@@ -334,13 +334,13 @@ it('detects environment variable value changes without exposing secret values',
$change = collect($diff->changes())->firstWhere('label', 'API_TOKEN');
expect($change)->not->toBeNull()
->and($change['display_summary'])->toBe('Changed')
->and($change['old_display_value'])->toBe('••••••••')
->and($change['new_display_value'])->toBe('••••••••')
->and(json_encode($diff->toArray()))->not->toContain('old-secret')->not->toContain('new-secret');
->and($change['display_summary'])->toBeNull()
->and($change['old_display_value'])->toBe('old-secret')
->and($change['new_display_value'])->toBe('new-secret')
->and(json_encode($diff->toArray()))->toContain('old-secret')->toContain('new-secret');
});
it('describes added environment variables as set without exposing secret values', function () {
it('describes added unlocked environment variables with their value', function () {
$application = snapshotTestApplication();
markSnapshotTestApplicationDeployed($application);
@@ -361,6 +361,6 @@ it('describes added environment variables as set without exposing secret values'
expect($change)->not->toBeNull()
->and($change['display_summary'])->toBeNull()
->and($change['old_display_value'])->toBe('-')
->and($change['new_display_value'])->toBe('••••••••')
->and(json_encode($diff->toArray()))->not->toContain('new-secret');
->and($change['new_display_value'])->toBe('new-secret')
->and(json_encode($diff->toArray()))->toContain('new-secret');
});
+30
View File
@@ -0,0 +1,30 @@
<?php
use App\Models\OauthSetting;
use Tests\TestCase;
uses(TestCase::class);
it('requires issuer url client id and client secret for oidc settings', function () {
$setting = new OauthSetting(['provider' => 'oidc']);
expect($setting->couldBeEnabled())->toBeFalse();
$setting->fill([
'client_id' => 'client-id',
'client_secret' => 'secret',
'base_url' => 'https://idp.example.com',
]);
expect($setting->couldBeEnabled())->toBeTrue();
});
it('returns configured scopes and custom login label', function () {
$setting = new OauthSetting([
'provider' => 'oidc',
'scopes' => 'openid email profile groups',
'custom_label' => 'Login with Okta',
]);
expect($setting->scopeList())->toBe(['openid', 'email', 'profile', 'groups'])
->and($setting->loginLabel())->toBe('Login with Okta');
});
+119
View File
@@ -0,0 +1,119 @@
<?php
use App\Auth\Oidc\Exceptions\OidcDiscoveryException;
use App\Auth\Oidc\Exceptions\OidcJwksException;
use App\Auth\Oidc\OidcDiscoveryService;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
uses(TestCase::class);
it('fetches and caches discovery documents and jwks', function () {
Cache::flush();
Http::fake([
'https://idp.example.com/.well-known/openid-configuration' => Http::response([
'issuer' => 'https://idp.example.com',
'authorization_endpoint' => 'https://idp.example.com/auth',
'token_endpoint' => 'https://idp.example.com/token',
'userinfo_endpoint' => 'https://idp.example.com/userinfo',
'jwks_uri' => 'https://idp.example.com/jwks',
]),
'https://idp.example.com/jwks' => Http::response(['keys' => [['kid' => 'one']]]),
]);
$service = app(OidcDiscoveryService::class);
$discovery = $service->discover('https://idp.example.com');
$jwks = $service->jwks($discovery->jwksUri);
expect($discovery->issuer)->toBe('https://idp.example.com')
->and($jwks['keys'][0]['kid'])->toBe('one');
Http::assertSentCount(2);
$service->discover('https://idp.example.com');
$service->jwks('https://idp.example.com/jwks');
Http::assertSentCount(2);
});
it('does not cache discovery documents with mismatched issuers', function () {
Cache::flush();
Http::fakeSequence('https://idp.example.com/.well-known/openid-configuration')
->push([
'issuer' => 'https://evil.example.com',
'authorization_endpoint' => 'https://idp.example.com/auth',
'token_endpoint' => 'https://idp.example.com/token',
'userinfo_endpoint' => 'https://idp.example.com/userinfo',
'jwks_uri' => 'https://idp.example.com/jwks',
])
->push([
'issuer' => 'https://idp.example.com',
'authorization_endpoint' => 'https://idp.example.com/auth',
'token_endpoint' => 'https://idp.example.com/token',
'userinfo_endpoint' => 'https://idp.example.com/userinfo',
'jwks_uri' => 'https://idp.example.com/jwks',
]);
$service = app(OidcDiscoveryService::class);
$cacheKey = 'oidc:discovery:'.hash('sha256', 'https://idp.example.com');
expect(fn () => $service->discover('https://idp.example.com'))
->toThrow(OidcDiscoveryException::class, 'Discovery issuer does not match the configured issuer URL.')
->and(Cache::has($cacheKey))->toBeFalse()
->and($service->discover('https://idp.example.com')->issuer)->toBe('https://idp.example.com');
Http::assertSentCount(2);
});
it('refetches jwks once on forced refresh to pick up rotated keys', function () {
Cache::flush();
Http::fakeSequence('https://idp.example.com/jwks')
->push(['keys' => [['kid' => 'old']]])
->push(['keys' => [['kid' => 'new']]]);
$service = app(OidcDiscoveryService::class);
expect($service->jwks('https://idp.example.com/jwks')['keys'][0]['kid'])->toBe('old');
// Forced refresh bypasses the cache and sees the rotated key.
expect($service->jwks('https://idp.example.com/jwks', true)['keys'][0]['kid'])->toBe('new');
Http::assertSentCount(2);
// Cooldown prevents a second immediate upstream fetch; cached value returned.
expect($service->jwks('https://idp.example.com/jwks', true)['keys'][0]['kid'])->toBe('new');
Http::assertSentCount(2);
});
it('rejects invalid discovery and jwks payloads', function () {
Cache::flush();
Http::fake([
'https://bad.example.com/.well-known/openid-configuration' => Http::response(['issuer' => 'https://bad.example.com']),
]);
app(OidcDiscoveryService::class)->discover('https://bad.example.com');
})->throws(OidcDiscoveryException::class);
it('rejects jwks responses without keys', function () {
Cache::flush();
Http::fake([
'https://idp.example.com/jwks' => Http::response(['empty' => true]),
]);
app(OidcDiscoveryService::class)->jwks('https://idp.example.com/jwks');
})->throws(OidcJwksException::class);
it('rejects non-https issuer urls', function () {
Cache::flush();
Http::fake();
app(OidcDiscoveryService::class)->discover('http://idp.example.com');
})->throws(OidcDiscoveryException::class, 'Issuer URL must be an absolute HTTPS URL.');
it('rejects non-https jwks uris', function () {
Cache::flush();
Http::fake();
app(OidcDiscoveryService::class)->jwks('http://idp.example.com/jwks');
})->throws(OidcJwksException::class, 'JWKS URI must be an absolute HTTPS URL.');
+148
View File
@@ -0,0 +1,148 @@
<?php
use App\Auth\Oidc\Exceptions\OidcException;
use App\Auth\Oidc\OidcConfig;
use App\Auth\Oidc\OidcDiscoveryDocument;
use App\Auth\Oidc\OidcDiscoveryService;
use App\Auth\Oidc\OidcTokenValidator;
use App\Auth\Oidc\Socialite\OidcProvider;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\Psr7\Response;
use Illuminate\Http\Request;
use Illuminate\Session\ArraySessionHandler;
use Illuminate\Session\Store;
use Illuminate\Support\Carbon;
use Mockery\MockInterface;
use Tests\TestCase;
uses(TestCase::class);
class TestOidcProviderWithExposedAuthUrl extends OidcProvider
{
public function authUrlForState(string $state): string
{
return $this->getAuthUrl($state);
}
}
function oidc_provider_discovery_document(): OidcDiscoveryDocument
{
return new OidcDiscoveryDocument(
issuer: 'https://idp.example.com',
authorizationEndpoint: 'https://idp.example.com/oauth2/authorize',
tokenEndpoint: 'https://idp.example.com/oauth2/token',
userinfoEndpoint: 'https://idp.example.com/oauth2/userinfo',
jwksUri: 'https://idp.example.com/.well-known/jwks.json',
);
}
function oidc_provider_session(): Store
{
$session = new Store('testing', new ArraySessionHandler(1200));
$session->start();
return $session;
}
function oidc_provider_request(Store $session, string $state = 'state-value'): Request
{
$request = Request::create('/auth/oidc/callback', 'GET', ['state' => $state]);
$request->setLaravelSession($session);
return $request;
}
function oidc_provider(Request $request): TestOidcProviderWithExposedAuthUrl
{
/** @var OidcDiscoveryService&MockInterface $discoveryService */
$discoveryService = Mockery::mock(OidcDiscoveryService::class);
$discoveryService->shouldReceive('discover')
->byDefault()
->with('https://idp.example.com')
->andReturn(oidc_provider_discovery_document());
/** @var OidcTokenValidator&MockInterface $tokenValidator */
$tokenValidator = Mockery::mock(OidcTokenValidator::class);
return (new TestOidcProviderWithExposedAuthUrl(
$request,
$discoveryService,
$tokenValidator,
'client-id',
'client-secret',
'https://coolify.example.com/auth/oidc/callback',
))->setConfig(new OidcConfig(
issuerUrl: 'https://idp.example.com',
clientId: 'client-id',
clientSecret: 'client-secret',
redirectUri: 'https://coolify.example.com/auth/oidc/callback',
usePkce: true,
));
}
it('stores oidc nonce and pkce verifier with a ten minute expiry', function () {
Carbon::setTestNow('2026-06-15 12:00:00');
try {
$session = oidc_provider_session();
$provider = oidc_provider(oidc_provider_request($session));
$provider->authUrlForState('state-value');
$nonceEntry = $session->get('oidc.nonce.state-value');
$verifierEntry = $session->get('oidc.code_verifier.state-value');
expect($nonceEntry)->toBeArray()
->and($nonceEntry['value'])->toBeString()->not->toBeEmpty()
->and($nonceEntry['expires_at'])->toBe(now()->addMinutes(10)->timestamp)
->and($verifierEntry)->toBeArray()
->and($verifierEntry['value'])->toBeString()->not->toBeEmpty()
->and($verifierEntry['expires_at'])->toBe(now()->addMinutes(10)->timestamp);
} finally {
Carbon::setTestNow();
}
});
it('sends a fresh oidc pkce verifier during token exchange', function () {
$session = oidc_provider_session();
$session->put('oidc.code_verifier.state-value', [
'value' => 'fresh-verifier',
'expires_at' => now()->addMinute()->timestamp,
]);
$provider = oidc_provider(oidc_provider_request($session));
$history = [];
$handler = HandlerStack::create(new MockHandler([
new Response(200, [], json_encode(['access_token' => 'access-token', 'id_token' => 'id-token'], JSON_THROW_ON_ERROR)),
]));
$handler->push(Middleware::history($history));
$provider->setHttpClient(new Client(['handler' => $handler]));
$provider->getAccessTokenResponse('authorization-code');
parse_str((string) $history[0]['request']->getBody(), $tokenRequestFields);
expect($tokenRequestFields['code_verifier'] ?? null)->toBe('fresh-verifier')
->and($session->has('oidc.code_verifier.state-value'))->toBeFalse();
});
it('throws a session expired error for an expired oidc pkce verifier during token exchange', function () {
$session = oidc_provider_session();
$session->put('oidc.code_verifier.state-value', [
'value' => 'expired-verifier',
'expires_at' => now()->subSecond()->timestamp,
]);
$provider = oidc_provider(oidc_provider_request($session));
$history = [];
$handler = HandlerStack::create(new MockHandler([
new Response(200, [], json_encode(['access_token' => 'access-token', 'id_token' => 'id-token'], JSON_THROW_ON_ERROR)),
]));
$handler->push(Middleware::history($history));
$provider->setHttpClient(new Client(['handler' => $handler]));
$provider->getAccessTokenResponse('authorization-code');
})->throws(OidcException::class, 'OIDC login session expired. Please try again.');
+187
View File
@@ -0,0 +1,187 @@
<?php
use App\Auth\Oidc\Exceptions\OidcSigningKeyNotFoundException;
use App\Auth\Oidc\Exceptions\OidcTokenException;
use App\Auth\Oidc\OidcDiscoveryDocument;
use App\Auth\Oidc\OidcTokenValidator;
use Tests\TestCase;
uses(TestCase::class);
function oidc_base64url(string $value): string
{
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
}
function oidc_keyset(string $kid = 'test-key'): array
{
$privateKey = openssl_pkey_new([
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
openssl_pkey_export($privateKey, $privatePem);
$details = openssl_pkey_get_details($privateKey);
return [
'private_pem' => $privatePem,
'jwks' => [
'keys' => [[
'kty' => 'RSA',
'kid' => $kid,
'alg' => 'RS256',
'use' => 'sig',
'n' => oidc_base64url($details['rsa']['n']),
'e' => oidc_base64url($details['rsa']['e']),
]],
],
];
}
function oidc_token(array $claims, string $privatePem, string $kid = 'test-key', string $algorithm = 'RS256'): string
{
$header = oidc_base64url(json_encode(['alg' => $algorithm, 'typ' => 'JWT', 'kid' => $kid], JSON_THROW_ON_ERROR));
$payload = oidc_base64url(json_encode($claims, JSON_THROW_ON_ERROR));
$signatureInput = $header.'.'.$payload;
openssl_sign($signatureInput, $signature, $privatePem, OPENSSL_ALGO_SHA256);
return $signatureInput.'.'.oidc_base64url($signature);
}
function oidc_discovery(): OidcDiscoveryDocument
{
return new OidcDiscoveryDocument(
issuer: 'https://idp.example.com',
authorizationEndpoint: 'https://idp.example.com/oauth2/authorize',
tokenEndpoint: 'https://idp.example.com/oauth2/token',
userinfoEndpoint: 'https://idp.example.com/oauth2/userinfo',
jwksUri: 'https://idp.example.com/.well-known/jwks.json',
);
}
it('validates a well formed RS256 id token', function () {
$keyset = oidc_keyset();
$now = time();
$token = oidc_token([
'iss' => 'https://idp.example.com',
'aud' => 'client-id',
'sub' => 'okta-user-1',
'iat' => $now,
'exp' => $now + 600,
'nonce' => 'expected-nonce',
'email' => 'User@Example.com',
], $keyset['private_pem']);
$claims = app(OidcTokenValidator::class)->validate(
idToken: $token,
discovery: oidc_discovery(),
jwks: $keyset['jwks'],
clientId: 'client-id',
expectedNonce: 'expected-nonce',
);
expect($claims['sub'])->toBe('okta-user-1')
->and($claims['email'])->toBe('User@Example.com');
});
it('rejects invalid token claims', function (array $claimOverrides, string $message) {
$keyset = oidc_keyset();
$now = time();
$claims = array_merge([
'iss' => 'https://idp.example.com',
'aud' => 'client-id',
'sub' => 'okta-user-1',
'iat' => $now,
'exp' => $now + 600,
'nonce' => 'expected-nonce',
], $claimOverrides);
$token = oidc_token($claims, $keyset['private_pem']);
app(OidcTokenValidator::class)->validate(
idToken: $token,
discovery: oidc_discovery(),
jwks: $keyset['jwks'],
clientId: 'client-id',
expectedNonce: 'expected-nonce',
);
})->throws(OidcTokenException::class)->with([
'issuer mismatch' => [['iss' => 'https://evil.example.com'], 'issuer'],
'audience mismatch' => [['aud' => 'other-client'], 'audience'],
'azp missing for multi audience' => [['aud' => ['client-id', 'other-client']], 'azp'],
'azp mismatch' => [['aud' => ['client-id', 'other-client'], 'azp' => 'other-client'], 'azp'],
'expired token' => [['exp' => time() - 3600], 'expired'],
'future issued at' => [['iat' => time() + 3600], 'issued'],
'nonce mismatch' => [['nonce' => 'wrong-nonce'], 'nonce'],
'missing subject' => [['sub' => null], 'subject'],
'empty subject' => [['sub' => ''], 'subject'],
'non-string subject' => [['sub' => 123], 'subject'],
]);
it('rejects a bad signature and unknown key id', function (string $kid) {
$keyset = oidc_keyset('test-key');
$otherKeyset = oidc_keyset($kid);
$now = time();
$token = oidc_token([
'iss' => 'https://idp.example.com',
'aud' => 'client-id',
'sub' => 'okta-user-1',
'iat' => $now,
'exp' => $now + 600,
'nonce' => 'expected-nonce',
], $otherKeyset['private_pem'], $kid);
app(OidcTokenValidator::class)->validate(
idToken: $token,
discovery: oidc_discovery(),
jwks: $keyset['jwks'],
clientId: 'client-id',
expectedNonce: 'expected-nonce',
);
})->throws(OidcTokenException::class)->with([
'same kid with bad signature' => ['test-key'],
'unknown kid' => ['other-key'],
]);
it('rejects disallowed algorithms', function () {
$keyset = oidc_keyset();
$now = time();
$token = oidc_token([
'iss' => 'https://idp.example.com',
'aud' => 'client-id',
'sub' => 'okta-user-1',
'iat' => $now,
'exp' => $now + 600,
], $keyset['private_pem'], algorithm: 'HS256');
app(OidcTokenValidator::class)->validate($token, oidc_discovery(), $keyset['jwks'], 'client-id');
})->throws(OidcTokenException::class);
it('throws a dedicated exception when the signing key is unknown', function () {
$keyset = oidc_keyset('current-key');
$token = oidc_token([
'iss' => 'https://idp.example.com',
'aud' => 'client-id',
'sub' => 'okta-user-1',
'iat' => time(),
'exp' => time() + 600,
], $keyset['private_pem'], 'rotated-key');
app(OidcTokenValidator::class)->validate($token, oidc_discovery(), $keyset['jwks'], 'client-id');
})->throws(OidcSigningKeyNotFoundException::class);
it('rejects a jwks key not designated for signing', function () {
$keyset = oidc_keyset();
$keyset['jwks']['keys'][0]['use'] = 'enc';
$now = time();
$token = oidc_token([
'iss' => 'https://idp.example.com',
'aud' => 'client-id',
'sub' => 'okta-user-1',
'iat' => $now,
'exp' => $now + 600,
], $keyset['private_pem']);
// An encryption-only key is dropped from the keyset, so the kid no longer resolves.
app(OidcTokenValidator::class)->validate($token, oidc_discovery(), $keyset['jwks'], 'client-id');
})->throws(OidcTokenException::class);
+10
View File
@@ -23,6 +23,16 @@ class SshMultiplexingDisableTest extends TestCase
);
}
public function test_remote_shell_prefers_bash_and_falls_back_to_sh()
{
$reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'remoteShellCommand');
$this->assertSame(
'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi',
$reflection->invoke(null)
);
}
public function test_generate_ssh_command_accepts_disable_multiplexing_parameter()
{
$reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'generateSshCommand');
+17 -1
View File
@@ -4,6 +4,7 @@ use App\Livewire\Project\Shared\Danger;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\OauthIdentity;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
@@ -18,7 +19,7 @@ use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::create(['id' => 0]);
InstanceSettings::forceCreate(['id' => 0]);
Queue::fake();
$this->user = User::factory()->create([
@@ -70,6 +71,21 @@ test('delete succeeds with correct password and redirects', function () {
expect(Application::find($this->application->id))->toBeNull();
});
test('delete succeeds without password for an oauth user', function () {
OauthIdentity::create([
'user_id' => $this->user->id,
'provider' => 'oidc',
'issuer' => 'https://idp.example.com',
'provider_user_id' => 'oauth-user-id',
]);
Livewire::test(Danger::class, ['resource' => $this->application])
->call('delete', '')
->assertHasNoErrors();
expect(Application::find($this->application->id))->toBeNull();
});
test('delete applies selectedActions from checkbox state', function () {
$component = Livewire::test(Danger::class, ['resource' => $this->application])
->call('delete', 'test-password', ['delete_configurations', 'docker_cleanup']);