refactor(livewire): make data-sync helpers private (#11578)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-09-01 18:44:18 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 2018e7f329
commit 7c3b05e2d2
42 changed files with 525 additions and 80 deletions
+1 -1
View File
@@ -43,7 +43,7 @@ class Show extends Component
}
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
+5 -2
View File
@@ -85,11 +85,10 @@ class Discord extends Component
}
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
$this->authorize('update', $this->settings);
$this->settings->discord_enabled = $this->discordEnabled;
$this->settings->discord_webhook_url = $this->discordWebhookUrl;
@@ -174,6 +173,7 @@ class Discord extends Component
public function instantSave()
{
try {
$this->authorize('update', $this->settings);
$this->syncData(true);
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -184,6 +184,7 @@ class Discord extends Component
{
try {
$this->resetErrorBag();
$this->authorize('update', $this->settings);
$this->syncData(true);
$this->saveModel();
} catch (\Throwable $e) {
@@ -193,6 +194,8 @@ class Discord extends Component
public function saveModel()
{
$this->authorize('update', $this->settings);
$this->syncData(true);
refreshSession();
$this->dispatch('success', 'Settings saved.');
+3 -2
View File
@@ -132,12 +132,11 @@ class Email extends Component
}
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
$this->validate(['smtpEhloDomain' => ['nullable', 'string', new ValidHostname]]);
$this->authorize('update', $this->settings);
$this->settings->smtp_enabled = $this->smtpEnabled;
$this->settings->smtp_from_address = $this->smtpFromAddress;
$this->settings->smtp_from_name = $this->smtpFromName;
@@ -224,6 +223,8 @@ class Email extends Component
public function saveModel()
{
$this->authorize('update', $this->settings);
$this->syncData(true);
$this->dispatch('success', 'Email notifications settings updated.');
}
+5 -2
View File
@@ -89,11 +89,10 @@ class Pushover extends Component
}
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
$this->authorize('update', $this->settings);
$this->settings->pushover_enabled = $this->pushoverEnabled;
$this->settings->pushover_user_key = $this->pushoverUserKey;
$this->settings->pushover_api_token = $this->pushoverApiToken;
@@ -167,6 +166,7 @@ class Pushover extends Component
public function instantSave()
{
try {
$this->authorize('update', $this->settings);
$this->syncData(true);
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -179,6 +179,7 @@ class Pushover extends Component
{
try {
$this->resetErrorBag();
$this->authorize('update', $this->settings);
$this->syncData(true);
$this->saveModel();
} catch (\Throwable $e) {
@@ -188,6 +189,8 @@ class Pushover extends Component
public function saveModel()
{
$this->authorize('update', $this->settings);
$this->syncData(true);
refreshSession();
$this->dispatch('success', 'Settings saved.');
+5 -2
View File
@@ -87,11 +87,10 @@ class Slack extends Component
}
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
$this->authorize('update', $this->settings);
$this->settings->slack_enabled = $this->slackEnabled;
$this->settings->slack_webhook_url = $this->slackWebhookUrl;
@@ -158,6 +157,7 @@ class Slack extends Component
public function instantSave()
{
try {
$this->authorize('update', $this->settings);
$this->syncData(true);
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -170,6 +170,7 @@ class Slack extends Component
{
try {
$this->resetErrorBag();
$this->authorize('update', $this->settings);
$this->syncData(true);
$this->saveModel();
} catch (\Throwable $e) {
@@ -179,6 +180,8 @@ class Slack extends Component
public function saveModel()
{
$this->authorize('update', $this->settings);
$this->syncData(true);
refreshSession();
$this->dispatch('success', 'Settings saved.');
+5 -2
View File
@@ -134,11 +134,10 @@ class Telegram extends Component
}
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
$this->authorize('update', $this->settings);
$this->settings->telegram_enabled = $this->telegramEnabled;
$this->settings->telegram_token = $this->telegramToken;
$this->settings->telegram_chat_id = $this->telegramChatId;
@@ -223,6 +222,7 @@ class Telegram extends Component
public function instantSave()
{
try {
$this->authorize('update', $this->settings);
$this->syncData(true);
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -235,6 +235,7 @@ class Telegram extends Component
{
try {
$this->resetErrorBag();
$this->authorize('update', $this->settings);
$this->syncData(true);
$this->saveModel();
} catch (\Throwable $e) {
@@ -264,6 +265,8 @@ class Telegram extends Component
public function saveModel()
{
$this->authorize('update', $this->settings);
$this->syncData(true);
refreshSession();
$this->dispatch('success', 'Settings saved.');
+5 -2
View File
@@ -82,11 +82,10 @@ class Webhook extends Component
}
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
$this->authorize('update', $this->settings);
$this->settings->webhook_enabled = $this->webhookEnabled;
$this->settings->webhook_url = $this->webhookUrl;
@@ -152,6 +151,7 @@ class Webhook extends Component
public function instantSave()
{
try {
$this->authorize('update', $this->settings);
$this->syncData(true);
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -162,6 +162,7 @@ class Webhook extends Component
{
try {
$this->resetErrorBag();
$this->authorize('update', $this->settings);
$this->syncData(true);
$this->saveModel();
} catch (\Throwable $e) {
@@ -171,6 +172,8 @@ class Webhook extends Component
public function saveModel()
{
$this->authorize('update', $this->settings);
$this->syncData(true);
refreshSession();
@@ -99,7 +99,7 @@ class Advanced extends Component
}
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
+1 -1
View File
@@ -330,7 +330,7 @@ class General extends Component
$this->syncData();
}
public function syncData(bool $toModel = false): void
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
+1 -1
View File
@@ -65,7 +65,7 @@ class Source extends Component
$this->gitCommitSha = trim($this->gitCommitSha);
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
+1 -1
View File
@@ -31,7 +31,7 @@ class Swarm extends Component
}
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
+1 -1
View File
@@ -128,7 +128,7 @@ class BackupEdit extends Component
$this->status = $database->status;
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->backup->enabled = $this->backupEnabled;
@@ -121,7 +121,7 @@ class General extends Component
);
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
@@ -199,6 +199,7 @@ class General extends Component
}
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->authorize('update', $this->database);
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@@ -115,7 +115,7 @@ class General extends Component
);
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
@@ -191,6 +191,7 @@ class General extends Component
}
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->authorize('update', $this->database);
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
+1 -1
View File
@@ -34,7 +34,7 @@ class Health extends Component
$this->syncData();
}
public function syncData(bool $toModel = false): void
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
@@ -118,7 +118,7 @@ class General extends Component
);
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
@@ -196,6 +196,7 @@ class General extends Component
}
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->authorize('update', $this->database);
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@@ -136,7 +136,7 @@ class General extends Component
}
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
@@ -244,6 +244,7 @@ class General extends Component
}
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->authorize('update', $this->database);
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@@ -128,7 +128,7 @@ class General extends Component
}
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
@@ -237,6 +237,7 @@ class General extends Component
}
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->authorize('update', $this->database);
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@@ -136,7 +136,7 @@ class General extends Component
}
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
@@ -244,6 +244,7 @@ class General extends Component
}
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->authorize('update', $this->database);
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@@ -149,7 +149,7 @@ class General extends Component
}
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
@@ -240,6 +240,7 @@ class General extends Component
}
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->authorize('update', $this->database);
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@@ -127,7 +127,7 @@ class General extends Component
}
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
@@ -235,6 +235,7 @@ class General extends Component
}
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->authorize('update', $this->database);
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
+1 -1
View File
@@ -77,7 +77,7 @@ class Edit extends Component
}
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
+1 -1
View File
@@ -48,7 +48,7 @@ class EnvironmentEdit extends Component
}
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
+1 -1
View File
@@ -46,7 +46,7 @@ class EditDomain extends Component
$this->syncData();
}
public function syncData(bool $toModel = false): void
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
+1 -1
View File
@@ -120,7 +120,7 @@ class FileStorage extends Component
: route('project.application.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid]);
}
public function syncData(bool $toModel = false): void
private function syncData(bool $toModel = false): void
{
if ($toModel) {
if ($this->fileStorage->is_too_large) {
@@ -162,7 +162,7 @@ class Show extends Component
$this->valuesLoaded = true;
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->key = ValidationPatterns::normalizeEnvironmentVariableKey($this->key);
+1 -1
View File
@@ -86,7 +86,7 @@ class HealthChecks extends Component
}
}
public function syncData(bool $toModel = false): void
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
@@ -102,7 +102,7 @@ class Add extends Component
}
}
public function saveScheduledTask()
private function saveScheduledTask(): mixed
{
try {
$task = new ScheduledTask;
@@ -87,7 +87,7 @@ class Show extends Component
}
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
+3 -2
View File
@@ -42,10 +42,9 @@ class Advanced extends Component
}
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->authorize('update', $this->server);
$this->validate();
$this->server->settings->concurrent_builds = $this->concurrentBuilds;
$this->server->settings->dynamic_timeout = $this->dynamicTimeout;
@@ -67,6 +66,7 @@ class Advanced extends Component
public function instantSave()
{
try {
$this->authorize('update', $this->server);
$this->syncData(true);
$this->dispatch('success', 'Server updated.');
} catch (\Throwable $e) {
@@ -81,6 +81,7 @@ class Advanced extends Component
$this->serverDiskUsageCheckFrequency = $this->server->settings->getOriginal('server_disk_usage_check_frequency');
throw new \Exception('Invalid Cron / Human expression for Disk Usage Check Frequency.');
}
$this->authorize('update', $this->server);
$this->syncData(true);
$this->dispatch('success', 'Server updated.');
} catch (\Throwable $e) {
+3 -2
View File
@@ -97,10 +97,9 @@ class DockerCleanup extends Component
}
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->authorize('update', $this->server);
$this->validate();
$this->server->settings->force_docker_cleanup = $this->forceDockerCleanup;
$this->server->settings->docker_cleanup_frequency = $this->dockerCleanupFrequency;
@@ -122,6 +121,7 @@ class DockerCleanup extends Component
public function instantSave()
{
try {
$this->authorize('update', $this->server);
$this->syncData(true);
$this->dispatch('success', 'Server updated.');
} catch (\Throwable $e) {
@@ -147,6 +147,7 @@ class DockerCleanup extends Component
$this->dockerCleanupFrequency = $this->server->settings->getOriginal('docker_cleanup_frequency');
throw new \Exception('Invalid Cron / Human expression for Docker Cleanup Frequency.');
}
$this->authorize('update', $this->server);
$this->syncData(true);
$this->dispatch('success', 'Server updated.');
} catch (\Throwable $e) {
+4 -4
View File
@@ -52,7 +52,7 @@ class LogDrains extends Component
}
}
public function syncDataNewRelic(bool $toModel = false)
private function syncDataNewRelic(bool $toModel = false): void
{
if ($toModel) {
$this->server->settings->is_logdrain_newrelic_enabled = $this->isLogDrainNewRelicEnabled;
@@ -65,7 +65,7 @@ class LogDrains extends Component
}
}
public function syncDataAxiom(bool $toModel = false)
private function syncDataAxiom(bool $toModel = false): void
{
if ($toModel) {
$this->server->settings->is_logdrain_axiom_enabled = $this->isLogDrainAxiomEnabled;
@@ -78,7 +78,7 @@ class LogDrains extends Component
}
}
public function syncDataCustom(bool $toModel = false)
private function syncDataCustom(bool $toModel = false): void
{
if ($toModel) {
$this->server->settings->is_logdrain_custom_enabled = $this->isLogDrainCustomEnabled;
@@ -91,7 +91,7 @@ class LogDrains extends Component
}
}
public function syncData(bool $toModel = false, ?string $type = null)
private function syncData(bool $toModel = false, ?string $type = null): void
{
if ($toModel) {
$this->customValidation();
@@ -62,10 +62,9 @@ class TerminalAccess extends Component
}
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->authorize('update', $this->server);
$this->validate();
// No other fields to sync for terminal access
} else {
+3 -2
View File
@@ -54,10 +54,9 @@ class Sentinel extends Component
$this->syncData();
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->authorize('update', $this->server);
$this->validate();
$this->server->settings->is_metrics_enabled = $this->isMetricsEnabled;
$this->server->settings->sentinel_token = $this->sentinelToken;
@@ -145,6 +144,7 @@ class Sentinel extends Component
public function submit()
{
try {
$this->authorize('update', $this->server);
$this->syncData(true);
$this->dispatch('success', 'Sentinel settings updated. Restarting Sentinel.');
} catch (\Throwable $e) {
@@ -155,6 +155,7 @@ class Sentinel extends Component
public function instantSave()
{
try {
$this->authorize('update', $this->server);
$this->syncData(true);
$this->restartSentinel();
} catch (\Throwable $e) {
+4 -3
View File
@@ -230,12 +230,10 @@ class Show extends Component
->toArray();
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
$this->authorize('update', $this->server);
$foundServer = Server::where('ip', $this->ip)
->where('id', '!=', $this->server->id)
->first();
@@ -363,6 +361,7 @@ class Show extends Component
public function checkLocalhostConnection()
{
try {
$this->authorize('update', $this->server);
$this->syncData(true);
['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection();
if ($uptime) {
@@ -479,6 +478,7 @@ class Show extends Component
public function instantSave()
{
try {
$this->authorize('update', $this->server);
$this->syncData(true);
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -694,6 +694,7 @@ class Show extends Component
public function submit()
{
try {
$this->authorize('update', $this->server);
$this->syncData(true);
$this->dispatch('success', 'Server settings updated.');
} catch (\Throwable $e) {
+2 -2
View File
@@ -29,10 +29,9 @@ class Swarm extends Component
}
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->authorize('update', $this->server);
$this->server->settings->is_swarm_manager = $this->isSwarmManager;
$this->server->settings->is_swarm_worker = $this->isSwarmWorker;
$this->server->settings->save();
@@ -45,6 +44,7 @@ class Swarm extends Component
public function instantSave()
{
try {
$this->authorize('update', $this->server);
$this->syncData(true);
$this->dispatch('success', 'Swarm settings updated.');
} catch (\Throwable $e) {
+1 -1
View File
@@ -74,7 +74,7 @@ class SettingsEmail extends Component
$this->testEmailAddress = auth()->user()->email;
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
@@ -13,6 +13,7 @@ use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Exceptions\MethodNotFoundException;
use Livewire\Livewire;
uses(RefreshDatabase::class);
@@ -188,6 +189,21 @@ test('member cannot submit application advanced settings', function () {
->assertDispatched('error');
});
test('the private application advanced syncData helper is not remotely callable', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$original = (bool) $this->application->settings->is_force_https_enabled;
$component = Livewire::test(ApplicationAdvanced::class, ['application' => $this->application])
->set('isForceHttpsEnabled', ! $original);
expect(fn () => $component->call('syncData', true))
->toThrow(MethodNotFoundException::class);
expect((bool) $this->application->settings->fresh()->is_force_https_enabled)->toBe($original);
});
// --- Application Rollback Livewire actions ---
test('member cannot save rollback settings', function () {
@@ -10,6 +10,7 @@ use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Exceptions\MethodNotFoundException;
use Livewire\Livewire;
uses(RefreshDatabase::class);
@@ -43,13 +44,14 @@ test('member cannot send test notification on discord', function () {
->assertDispatched('error');
});
test('member cannot update discord notification settings', function () {
test('the private discord syncData helper is not remotely callable', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(DiscordNotification::class)
->call('syncData', true)
->assertForbidden();
$component = Livewire::test(DiscordNotification::class);
expect(fn () => $component->call('syncData', true))
->toThrow(MethodNotFoundException::class);
});
test('admin can update discord notification settings', function () {
@@ -78,13 +80,14 @@ test('member cannot send test notification on slack', function () {
->assertDispatched('error');
});
test('member cannot update slack notification settings', function () {
test('the private slack syncData helper is not remotely callable', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(SlackNotification::class)
->call('syncData', true)
->assertForbidden();
$component = Livewire::test(SlackNotification::class);
expect(fn () => $component->call('syncData', true))
->toThrow(MethodNotFoundException::class);
});
test('admin can update slack notification settings', function () {
@@ -114,13 +117,14 @@ test('member cannot send test notification on telegram', function () {
->assertDispatched('error');
});
test('member cannot update telegram notification settings', function () {
test('the private telegram syncData helper is not remotely callable', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(TelegramNotification::class)
->call('syncData', true)
->assertForbidden();
$component = Livewire::test(TelegramNotification::class);
expect(fn () => $component->call('syncData', true))
->toThrow(MethodNotFoundException::class);
});
test('admin can update telegram notification settings', function () {
@@ -138,7 +142,7 @@ test('telegram restart limit thread id accepts 255 characters', function () {
Livewire::test(TelegramNotification::class)
->set('telegramNotificationsRestartLimitReachedThreadId', str_repeat('a', 255))
->call('syncData', true)
->call('submit')
->assertHasNoErrors(['telegramNotificationsRestartLimitReachedThreadId']);
expect($this->team->telegramNotificationSettings->fresh()->telegram_notifications_restart_limit_reached_thread_id)
@@ -151,8 +155,11 @@ test('telegram restart limit thread id rejects 256 characters', function () {
Livewire::test(TelegramNotification::class)
->set('telegramNotificationsRestartLimitReachedThreadId', str_repeat('a', 256))
->call('syncData', true)
->assertHasErrors(['telegramNotificationsRestartLimitReachedThreadId' => 'max']);
->call('submit')
->assertDispatched('error');
expect($this->team->telegramNotificationSettings->fresh()->telegram_notifications_restart_limit_reached_thread_id)
->not->toBe(str_repeat('a', 256));
});
test('member cannot view telegram thread ids', function () {
@@ -226,13 +233,14 @@ test('member cannot send test email notification', function () {
->assertDispatched('error');
});
test('member cannot update email notification settings', function () {
test('the private email syncData helper is not remotely callable', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(EmailNotification::class)
->call('syncData', true)
->assertForbidden();
$component = Livewire::test(EmailNotification::class);
expect(fn () => $component->call('syncData', true))
->toThrow(MethodNotFoundException::class);
});
test('member cannot update smtp email transport directly', function () {
@@ -330,13 +338,14 @@ test('member cannot send test notification on pushover', function () {
->assertDispatched('error');
});
test('member cannot update pushover notification settings', function () {
test('the private pushover syncData helper is not remotely callable', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(PushoverNotification::class)
->call('syncData', true)
->assertForbidden();
$component = Livewire::test(PushoverNotification::class);
expect(fn () => $component->call('syncData', true))
->toThrow(MethodNotFoundException::class);
});
test('admin can update pushover notification settings', function () {
@@ -365,13 +374,14 @@ test('member cannot send test notification on webhook', function () {
->assertDispatched('error');
});
test('member cannot update webhook notification settings', function () {
test('the private webhook syncData helper is not remotely callable', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(WebhookNotification::class)
->call('syncData', true)
->assertForbidden();
$component = Livewire::test(WebhookNotification::class);
expect(fn () => $component->call('syncData', true))
->toThrow(MethodNotFoundException::class);
});
test('admin can update webhook notification settings', function () {
@@ -0,0 +1,180 @@
<?php
use App\Livewire\Project\Shared\ScheduledTask\Add;
use App\Livewire\Project\Shared\ScheduledTask\Show;
use App\Models\Application;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\ScheduledTask;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Routing\Events\RouteMatched;
use Illuminate\Support\Facades\Event;
use Livewire\Exceptions\MethodNotFoundException;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutVite();
InstanceSettings::forceCreate(['id' => 0]);
$this->team = Team::factory()->create();
$this->owner = User::factory()->create();
$this->owner->teams()->attach($this->team, ['role' => 'owner']);
$this->member = User::factory()->create();
$this->member->teams()->attach($this->team, ['role' => 'member']);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = $this->project->environments()->first();
$this->application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$this->task = ScheduledTask::factory()->create([
'team_id' => $this->team->id,
'application_id' => $this->application->id,
'name' => 'validate-benign',
'command' => 'echo scheduled-ok',
'frequency' => '0 0 * * *',
'timeout' => 300,
'enabled' => true,
]);
});
afterEach(function () {
Event::forget(RouteMatched::class);
});
function bindApplicationScheduledTaskRoute(Application $application, ScheduledTask $task): void
{
$application->loadMissing('environment.project');
Event::listen(RouteMatched::class, function (RouteMatched $event) use ($application, $task): void {
$event->route->setParameter('task_uuid', $task->uuid);
$event->route->setParameter('project_uuid', $application->environment->project->uuid);
$event->route->setParameter('environment_uuid', $application->environment->uuid);
$event->route->setParameter('application_uuid', $application->uuid);
});
}
function originalScheduledTaskState(ScheduledTask $task): array
{
return $task->only(['name', 'command', 'frequency', 'timeout', 'enabled', 'container']);
}
test('read-only member can view an existing scheduled task', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
bindApplicationScheduledTaskRoute($this->application, $this->task);
Livewire::test(Show::class)
->assertSuccessful()
->assertSet('name', 'validate-benign')
->assertSet('command', 'echo scheduled-ok')
->assertSet('frequency', '0 0 * * *');
});
test('the private syncData helper is not remotely callable through Livewire', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
bindApplicationScheduledTaskRoute($this->application, $this->task);
$original = originalScheduledTaskState($this->task);
$component = Livewire::test(Show::class)
->set('name', 'member-edited')
->set('command', 'echo member-edited')
->set('frequency', '* * * * *')
->set('timeout', 120)
->set('isEnabled', true);
expect(fn () => $component->call('syncData', true))
->toThrow(MethodNotFoundException::class);
expect(originalScheduledTaskState($this->task->fresh()))->toBe($original);
});
test('read-only member cannot persist scheduled task changes through submit', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
bindApplicationScheduledTaskRoute($this->application, $this->task);
$original = originalScheduledTaskState($this->task);
Livewire::test(Show::class)
->set('name', 'member-edited')
->set('command', 'echo member-edited')
->set('frequency', '* * * * *')
->call('submit');
expect(originalScheduledTaskState($this->task->fresh()))->toBe($original);
});
test('the private saveScheduledTask helper is not remotely callable through Livewire', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$component = Livewire::test(Add::class, [
'id' => (string) $this->application->id,
'type' => 'application',
'containerNames' => collect(),
])
->set('name', 'member-created')
->set('command', 'id')
->set('frequency', '* * * * *')
->set('timeout', 300);
expect(fn () => $component->call('saveScheduledTask'))
->toThrow(MethodNotFoundException::class);
expect(ScheduledTask::query()->where('name', 'member-created')->exists())->toBeFalse();
});
test('read-only member cannot create a scheduled task through submit', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(Add::class, [
'id' => (string) $this->application->id,
'type' => 'application',
'containerNames' => collect(),
])
->set('name', 'member-created')
->set('command', 'id')
->set('frequency', '* * * * *')
->set('timeout', 300)
->call('submit');
expect(ScheduledTask::query()->where('name', 'member-created')->exists())->toBeFalse();
});
test('admin can persist scheduled task changes through submit', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
bindApplicationScheduledTaskRoute($this->application, $this->task);
Livewire::test(Show::class)
->set('name', 'updated-by-admin')
->set('command', 'echo admin-ok')
->set('frequency', '0 1 * * *')
->set('timeout', 600)
->call('submit')
->assertSuccessful();
$this->task->refresh();
expect($this->task->name)->toBe('updated-by-admin')
->and($this->task->command)->toBe('echo admin-ok')
->and($this->task->frequency)->toBe('0 1 * * *')
->and($this->task->timeout)->toBe(600);
});
@@ -3,6 +3,7 @@
use App\Http\Middleware\PreventRequestsDuringMaintenance;
use App\Livewire\Server\Create as ServerCreate;
use App\Livewire\Server\Index as ServerIndex;
use App\Livewire\Server\LogDrains;
use App\Livewire\Server\Navbar as ServerNavbar;
use App\Models\CloudProviderToken;
use App\Models\InstanceSettings;
@@ -12,6 +13,7 @@ use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Exceptions\MethodNotFoundException;
use Livewire\Livewire;
uses(RefreshDatabase::class);
@@ -79,6 +81,20 @@ test('member can view server', function () {
expect($this->member->can('view', $this->server))->toBeTrue();
});
test('the private log drain syncData helper is not remotely callable', function () {
$this->withoutVite();
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$component = Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid])
->set('isLogDrainAxiomEnabled', true);
expect(fn () => $component->call('syncData', true))
->toThrow(MethodNotFoundException::class);
expect((bool) $this->server->settings->fresh()->is_logdrain_axiom_enabled)->toBeFalse();
});
// --- Server Policy: manageProxy ---
test('admin can manage proxy', function () {
@@ -0,0 +1,196 @@
<?php
use PhpParser\Node;
use PhpParser\Node\Expr\ConstFetch;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Identifier;
use PhpParser\Node\Stmt\ClassMethod;
use PhpParser\Node\Stmt\If_;
use PhpParser\Node\Stmt\Return_;
use PhpParser\Node\Stmt\TryCatch;
use PhpParser\NodeFinder;
use PhpParser\ParserFactory;
/** @return list<array{path: string, method: ClassMethod}> */
function livewireSyncDataMethods(string $directory): array
{
$methods = [];
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory));
$parser = (new ParserFactory)->createForNewestSupportedVersion();
$finder = new NodeFinder;
foreach ($iterator as $file) {
if (! $file->isFile() || $file->getExtension() !== 'php') {
continue;
}
$path = $file->getPathname();
$contents = file_get_contents($path);
if ($contents === false) {
continue;
}
foreach ($finder->findInstanceOf($parser->parse($contents) ?? [], ClassMethod::class) as $method) {
if (preg_match('/^sync(Data|DatabaseData|ApplicationData)/', $method->name->toString())) {
$methods[] = ['path' => $path, 'method' => $method];
}
}
}
usort($methods, fn (array $a, array $b): int => [$a['path'], $a['method']->name->toString()] <=> [$b['path'], $b['method']->name->toString()]);
return $methods;
}
function livewireMethodCallName(MethodCall $call): ?string
{
return $call->name instanceof Identifier ? $call->name->toString() : null;
}
function livewireIsWriteSyncCall(MethodCall $call, string $syncMethod): bool
{
if (livewireMethodCallName($call) !== $syncMethod) {
return false;
}
foreach ($call->args as $position => $argument) {
if (($position === 0 || $argument->name?->toString() === 'toModel') && $argument->value instanceof ConstFetch && $argument->value->name->toLowerString() === 'true') {
return true;
}
}
return false;
}
/** @param list<Node\Stmt> $statements @param list<int> $unauthorizedLines */
function livewireScanStatements(array $statements, string $syncMethod, bool $authorized, array &$unauthorizedLines): bool
{
foreach ($statements as $statement) {
$authorized = livewireScanNode($statement, $syncMethod, $authorized, $unauthorizedLines);
}
return $authorized;
}
/** @param list<int> $unauthorizedLines */
function livewireScanNode(mixed $node, string $syncMethod, bool $authorized, array &$unauthorizedLines): bool
{
if (! $node instanceof Node && ! is_array($node)) {
return $authorized;
}
if (is_array($node)) {
return livewireScanStatements($node, $syncMethod, $authorized, $unauthorizedLines);
}
if ($node instanceof TryCatch) {
$tryAuthorized = livewireScanStatements($node->stmts, $syncMethod, $authorized, $unauthorizedLines);
$allCatchesTerminate = $node->catches !== [];
foreach ($node->catches as $catch) {
livewireScanStatements($catch->stmts, $syncMethod, $authorized, $unauthorizedLines);
$allCatchesTerminate = $allCatchesTerminate && collect($catch->stmts)->contains(fn (Node\Stmt $statement): bool => $statement instanceof Return_);
}
if ($node->finally !== null) {
livewireScanStatements($node->finally->stmts, $syncMethod, $authorized, $unauthorizedLines);
}
return $allCatchesTerminate ? $tryAuthorized : $authorized;
}
if ($node instanceof If_) {
livewireScanNode($node->cond, $syncMethod, $authorized, $unauthorizedLines);
livewireScanStatements($node->stmts, $syncMethod, $authorized, $unauthorizedLines);
foreach ($node->elseifs as $elseif) {
livewireScanNode($elseif->cond, $syncMethod, $authorized, $unauthorizedLines);
livewireScanStatements($elseif->stmts, $syncMethod, $authorized, $unauthorizedLines);
}
if ($node->else !== null) {
livewireScanStatements($node->else->stmts, $syncMethod, $authorized, $unauthorizedLines);
}
return $authorized;
}
if ($node instanceof MethodCall) {
$name = livewireMethodCallName($node);
if ($name === 'authorize') {
return true;
}
if (livewireIsWriteSyncCall($node, $syncMethod) && ! $authorized) {
$unauthorizedLines[] = $node->getStartLine();
}
}
foreach ($node->getSubNodeNames() as $name) {
$authorized = livewireScanNode($node->{$name}, $syncMethod, $authorized, $unauthorizedLines);
}
return $authorized;
}
/** @return list<int> */
function livewireUnauthorizedSyncWriteLines(ClassMethod $method, string $syncMethod): array
{
$lines = [];
livewireScanStatements($method->stmts ?? [], $syncMethod, false, $lines);
return $lines;
}
function livewireMethodFromSource(string $source, string $method): ClassMethod
{
$nodes = (new ParserFactory)->createForNewestSupportedVersion()->parse($source) ?? [];
return (new NodeFinder)->findFirst($nodes, fn (Node $node): bool => $node instanceof ClassMethod && $node->name->toString() === $method);
}
it('keeps every Livewire syncData helper private', function () {
$violations = [];
foreach (livewireSyncDataMethods(dirname(__DIR__, 2).'/app/Livewire') as $syncMethod) {
if (! $syncMethod['method']->isPrivate()) {
$relative = str_replace(dirname(__DIR__, 2).'/', '', $syncMethod['path']);
$violations[] = "{$relative}::{$syncMethod['method']->name}() is not private";
}
}
expect($violations)->toBeEmpty("Livewire syncData helpers must be private:\n".implode("\n", $violations));
});
it('discovers files that only define syncApplicationData helpers', function () {
$directory = sys_get_temp_dir().'/livewire-sync-'.uniqid();
mkdir($directory);
file_put_contents($directory.'/Example.php', '<?php class Example { private function syncApplicationData(bool $toModel = false) {} }');
$methods = livewireSyncDataMethods($directory);
unlink($directory.'/Example.php');
rmdir($directory);
expect($methods)->toHaveCount(1)
->and($methods[0]['method']->name->toString())->toBe('syncApplicationData');
});
it('detects named write arguments and does not carry authorization into catch paths', function () {
$method = livewireMethodFromSource(<<<'PHP'
<?php
class Example {
public function instantSave() {
try {
$this->authorize('update', $this->resource);
$this->syncData(toModel: true);
} catch (Throwable $exception) {
$this->syncData(toModel: true);
}
}
}
PHP, 'instantSave');
expect(livewireUnauthorizedSyncWriteLines($method, 'syncData'))->toHaveCount(1);
});
it('authorizes before every syncData write call', function () {
$violations = [];
$root = dirname(__DIR__, 2);
foreach (livewireSyncDataMethods($root.'/app/Livewire') as $syncMethod) {
$contents = file_get_contents($syncMethod['path']);
$nodes = (new ParserFactory)->createForNewestSupportedVersion()->parse($contents) ?? [];
foreach ((new NodeFinder)->findInstanceOf($nodes, ClassMethod::class) as $caller) {
foreach (livewireUnauthorizedSyncWriteLines($caller, $syncMethod['method']->name->toString()) as $line) {
$relative = str_replace($root.'/', '', $syncMethod['path']);
$violations[] = "{$relative}:{$line}::{$caller->name}() calls {$syncMethod['method']->name}(true) without prior authorization";
}
}
}
expect($violations)->toBeEmpty("Missing authorization before syncData write calls:\n".implode("\n", array_unique($violations)));
});