diff --git a/app/Livewire/Notifications/Concerns/TogglesNotificationEvents.php b/app/Livewire/Notifications/Concerns/TogglesNotificationEvents.php index 4433de0242..37291ba59b 100644 --- a/app/Livewire/Notifications/Concerns/TogglesNotificationEvents.php +++ b/app/Livewire/Notifications/Concerns/TogglesNotificationEvents.php @@ -33,6 +33,14 @@ trait TogglesNotificationEvents abort_unless(in_array($property, $allowedProperties, true), 404); $this->{$property} = ! $this->{$property}; - $this->saveModel(); + + try { + $this->saveModel(); + } catch (\Throwable $e) { + $this->{$property} = ! $this->{$property}; + $this->settings->refresh(); + + handleError($e, $this); + } } } diff --git a/app/Livewire/Notifications/Discord.php b/app/Livewire/Notifications/Discord.php index 8e0e149e27..469e47605c 100644 --- a/app/Livewire/Notifications/Discord.php +++ b/app/Livewire/Notifications/Discord.php @@ -143,13 +143,12 @@ class Discord extends Component public function instantSaveDiscordPingEnabled() { try { - $original = $this->discordPingEnabled; $this->validate([ 'discordPingEnabled' => 'required', ]); $this->saveModel(); } catch (\Throwable $e) { - $this->discordPingEnabled = $original; + $this->discordPingEnabled = (bool) $this->settings->refresh()->discord_ping_enabled; return handleError($e, $this); } @@ -158,7 +157,6 @@ class Discord extends Component public function instantSaveDiscordEnabled() { try { - $original = $this->discordEnabled; $this->validate([ 'discordWebhookUrl' => 'required', ], [ @@ -166,7 +164,7 @@ class Discord extends Component ]); $this->saveModel(); } catch (\Throwable $e) { - $this->discordEnabled = $original; + $this->discordEnabled = (bool) $this->settings->refresh()->discord_enabled; return handleError($e, $this); } diff --git a/app/Livewire/Notifications/Pushover.php b/app/Livewire/Notifications/Pushover.php index 5e9abd9407..a2fe63a0f8 100644 --- a/app/Livewire/Notifications/Pushover.php +++ b/app/Livewire/Notifications/Pushover.php @@ -157,7 +157,7 @@ class Pushover extends Component ]); $this->saveModel(); } catch (\Throwable $e) { - $this->pushoverEnabled = false; + $this->pushoverEnabled = (bool) $this->settings->refresh()->pushover_enabled; return handleError($e, $this); } finally { diff --git a/app/Livewire/Notifications/Slack.php b/app/Livewire/Notifications/Slack.php index a96b452f82..70ec6f786e 100644 --- a/app/Livewire/Notifications/Slack.php +++ b/app/Livewire/Notifications/Slack.php @@ -148,7 +148,7 @@ class Slack extends Component ]); $this->saveModel(); } catch (\Throwable $e) { - $this->slackEnabled = false; + $this->slackEnabled = (bool) $this->settings->refresh()->slack_enabled; return handleError($e, $this); } finally { diff --git a/app/Livewire/Notifications/Telegram.php b/app/Livewire/Notifications/Telegram.php index 6b362f7869..cd7bea60cd 100644 --- a/app/Livewire/Notifications/Telegram.php +++ b/app/Livewire/Notifications/Telegram.php @@ -257,7 +257,7 @@ class Telegram extends Component ]); $this->saveModel(); } catch (\Throwable $e) { - $this->telegramEnabled = false; + $this->telegramEnabled = (bool) $this->settings->refresh()->telegram_enabled; return handleError($e, $this); } finally { diff --git a/app/Livewire/Notifications/Webhook.php b/app/Livewire/Notifications/Webhook.php index dfc0fabd30..9b20695cc7 100644 --- a/app/Livewire/Notifications/Webhook.php +++ b/app/Livewire/Notifications/Webhook.php @@ -136,7 +136,6 @@ class Webhook extends Component public function instantSaveWebhookEnabled() { try { - $original = $this->webhookEnabled; $this->validate([ 'webhookUrl' => 'required', ], [ @@ -144,7 +143,7 @@ class Webhook extends Component ]); $this->saveModel(); } catch (\Throwable $e) { - $this->webhookEnabled = $original; + $this->webhookEnabled = (bool) $this->settings->refresh()->webhook_enabled; return handleError($e, $this); } diff --git a/resources/views/components/notification/channel-actions.blade.php b/resources/views/components/notification/channel-actions.blade.php index b4ad3c8bda..8a070f8d29 100644 --- a/resources/views/components/notification/channel-actions.blade.php +++ b/resources/views/components/notification/channel-actions.blade.php @@ -24,6 +24,7 @@ enabled = next; $wire.$set(enabledProperty, next) .then(() => $wire.$call(toggleMethod)) + .then(() => { enabled = Boolean($wire.$get(enabledProperty)); }) .catch(() => { enabled = !next; }); "> {{ $enabled ? 'Disable' : 'Enable' }} diff --git a/resources/views/livewire/profile/index.blade.php b/resources/views/livewire/profile/index.blade.php index 32f0421926..97e6ff74ee 100644 --- a/resources/views/livewire/profile/index.blade.php +++ b/resources/views/livewire/profile/index.blade.php @@ -260,8 +260,8 @@
- + :text="decrypt(request()->user()->two_factor_secret)" /> +
diff --git a/tests/Feature/EnableActionButtonsTest.php b/tests/Feature/EnableActionButtonsTest.php index 3cc044b886..afcb340063 100644 --- a/tests/Feature/EnableActionButtonsTest.php +++ b/tests/Feature/EnableActionButtonsTest.php @@ -142,3 +142,70 @@ it('keeps notification email smtp disabled when enable validation fails', functi ->assertDispatched('error') ->assertSet('smtpEnabled', false); }); + +it('reverts a notification provider toggle to the saved value when enabling fails', function (string $component, string $method, string $enabledProperty, string $settingsRelation, string $settingsColumn) { + [, $team] = actingAsEnableActionOwner(); + + Livewire::test($component) + ->set($enabledProperty, true) + ->call($method) + ->assertDispatched('error') + ->assertSet($enabledProperty, false); + + expect($team->{$settingsRelation}->fresh()->{$settingsColumn})->toBeFalse(); +})->with([ + 'discord' => [Discord::class, 'instantSaveDiscordEnabled', 'discordEnabled', 'discordNotificationSettings', 'discord_enabled'], + 'slack' => [Slack::class, 'instantSaveSlackEnabled', 'slackEnabled', 'slackNotificationSettings', 'slack_enabled'], + 'telegram' => [Telegram::class, 'instantSaveTelegramEnabled', 'telegramEnabled', 'telegramNotificationSettings', 'telegram_enabled'], + 'pushover' => [Pushover::class, 'instantSavePushoverEnabled', 'pushoverEnabled', 'pushoverNotificationSettings', 'pushover_enabled'], + 'webhook' => [Webhook::class, 'instantSaveWebhookEnabled', 'webhookEnabled', 'webhookNotificationSettings', 'webhook_enabled'], +]); + +it('reverts a notification provider toggle to the saved value when disabling fails', function (string $component, string $method, string $enabledProperty, string $requiredProperty, string $settingsRelation, array $savedSettings) { + [, $team] = actingAsEnableActionOwner(); + $team->{$settingsRelation}->update($savedSettings); + + Livewire::test($component) + ->assertSet($enabledProperty, true) + ->set($requiredProperty, '') + ->set($enabledProperty, false) + ->call($method) + ->assertDispatched('error') + ->assertSet($enabledProperty, true); + + expect($team->{$settingsRelation}->fresh()->{array_key_first($savedSettings)})->toBeTrue(); +})->with([ + 'discord' => [Discord::class, 'instantSaveDiscordEnabled', 'discordEnabled', 'discordWebhookUrl', 'discordNotificationSettings', ['discord_enabled' => true, 'discord_webhook_url' => 'https://discord.com/api/webhooks/1/abc']], + 'slack' => [Slack::class, 'instantSaveSlackEnabled', 'slackEnabled', 'slackWebhookUrl', 'slackNotificationSettings', ['slack_enabled' => true, 'slack_webhook_url' => 'https://hooks.slack.com/services/T/B/C']], + 'telegram' => [Telegram::class, 'instantSaveTelegramEnabled', 'telegramEnabled', 'telegramToken', 'telegramNotificationSettings', ['telegram_enabled' => true, 'telegram_token' => '123:abc', 'telegram_chat_id' => '42']], + 'pushover' => [Pushover::class, 'instantSavePushoverEnabled', 'pushoverEnabled', 'pushoverUserKey', 'pushoverNotificationSettings', ['pushover_enabled' => true, 'pushover_user_key' => 'user', 'pushover_api_token' => 'token']], + 'webhook' => [Webhook::class, 'instantSaveWebhookEnabled', 'webhookEnabled', 'webhookUrl', 'webhookNotificationSettings', ['webhook_enabled' => true, 'webhook_url' => 'https://hooks.example.com/coolify']], +]); + +it('reverts a notification event toggle to the saved value when saving fails', function (string $component, string $urlProperty, string $eventProperty, string $settingsRelation, string $eventColumn) { + [, $team] = actingAsEnableActionOwner(); + $saved = (bool) $team->{$settingsRelation}->{$eventColumn}; + + Livewire::test($component) + ->set($urlProperty, 'not-a-url') + ->call('toggleEvent', $eventProperty) + ->assertSet($eventProperty, $saved); + + expect((bool) $team->{$settingsRelation}->fresh()->{$eventColumn})->toBe($saved); +})->with([ + 'discord' => [Discord::class, 'discordWebhookUrl', 'deploymentSuccessDiscordNotifications', 'discordNotificationSettings', 'deployment_success_discord_notifications'], + 'slack' => [Slack::class, 'slackWebhookUrl', 'deploymentSuccessSlackNotifications', 'slackNotificationSettings', 'deployment_success_slack_notifications'], +]); + +it('reverts the discord mention setting to the saved value when saving fails', function () { + [, $team] = actingAsEnableActionOwner(); + $team->discordNotificationSettings->update(['discord_ping_enabled' => true]); + + Livewire::test(Discord::class) + ->set('discordWebhookUrl', 'not-a-url') + ->set('discordPingEnabled', false) + ->call('instantSaveDiscordPingEnabled') + ->assertSet('discordPingEnabled', true); + + expect($team->discordNotificationSettings->fresh()->discord_ping_enabled)->toBeTrue(); +}); diff --git a/tests/Feature/ProfileTwoFactorSetupTest.php b/tests/Feature/ProfileTwoFactorSetupTest.php new file mode 100644 index 0000000000..f6a084d62a --- /dev/null +++ b/tests/Feature/ProfileTwoFactorSetupTest.php @@ -0,0 +1,64 @@ +loadHTML(''.$html); + $xpath = new DOMXPath($document); + + $input = null; + foreach ($xpath->query('//input[@readonly]') as $node) { + if (str_starts_with(html_entity_decode($node->getAttribute('value')), 'otpauth')) { + $input = $node; + break; + } + } + expect($input)->not->toBeNull(); + + preg_match('/@click="copy\(await \(([^"]*otpauth[^"]*)\)\)"/', $html, $matches); + expect($matches)->toHaveKey(1); + $javascriptString = html_entity_decode($matches[1], ENT_QUOTES | ENT_HTML5); + + return [ + 'input' => $input->getAttribute('value'), + 'copied' => json_decode('"'.str_replace("\\'", "'", trim($javascriptString, "'")).'"'), + ]; +} + +it('renders the 2fa setup url escaped exactly once', function () { + config()->set('app.name', 'Coolify & Co'); + + $user = User::factory()->create(['name' => 'Two Factor User', 'email' => 'first+2fa@example.com']); + $user->forceFill([ + 'two_factor_secret' => encrypt(app(TwoFactorAuthenticationProvider::class)->generateSecretKey()), + ])->save(); + + $this->actingAs($user); + session(['status' => 'two-factor-authentication-enabled']); + + $expectedUrl = $user->fresh()->twoFactorQrCodeUrl(); + $rendered = renderedTwoFactorSetupUrl(Livewire::test(ProfileIndex::class)->html()); + + expect($rendered['input'])->toBe($expectedUrl) + ->and($rendered['copied'])->toBe($expectedUrl); + + $parts = parse_url($rendered['copied']); + parse_str($parts['query'], $query); + + expect($parts['scheme'])->toBe('otpauth') + ->and($parts['host'])->toBe('totp') + ->and(rawurldecode(ltrim($parts['path'], '/')))->toBe('Coolify & Co:first+2fa@example.com') + ->and($query['issuer'])->toBe('Coolify & Co') + ->and($query['secret'])->toBe(decrypt($user->fresh()->two_factor_secret)); +});