mirror of
https://github.com/coollabsio/coolify.git
synced 2026-08-24 10:05:47 -05:00
Merge branch 'next' into main
This commit is contained in:
@@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -1,2 +0,0 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -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());
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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&accountId=%2A&zoneId=all&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();
|
||||
});
|
||||
@@ -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>');
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 '));
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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.');
|
||||
@@ -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.');
|
||||
@@ -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);
|
||||
@@ -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');
|
||||
|
||||
@@ -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']);
|
||||
|
||||
Reference in New Issue
Block a user