Merge remote-tracking branch 'origin/next' into refactor/sync-model-attributes

# Conflicts:
#	app/Models/Application.php
#	app/Models/Service.php
#	app/Models/StandaloneClickhouse.php
#	app/Models/StandaloneDragonfly.php
#	app/Models/StandaloneKeydb.php
#	app/Models/StandaloneMariadb.php
#	app/Models/StandaloneMongodb.php
#	app/Models/StandaloneMysql.php
#	app/Models/StandalonePostgresql.php
#	app/Models/StandaloneRedis.php
#	app/Models/Team.php
#	app/Models/User.php
#	tests/Feature/MassAssignmentProtectionTest.php
This commit is contained in:
Andras Bacsai 2026-03-29 21:32:54 +02:00
commit 7ad51241f3
20 changed files with 297 additions and 174 deletions

View file

@ -37,12 +37,13 @@ public function create(array $input): User
if (User::count() == 0) {
// If this is the first user, make them the root user
// Team is already created in the database/seeders/ProductionSeeder.php
$user = User::create([
$user = (new User)->forceFill([
'id' => 0,
'name' => $input['name'],
'email' => $input['email'],
'password' => Hash::make($input['password']),
]);
$user->save();
$team = $user->teams()->first();
// Disable registration after first user is created

View file

@ -14,14 +14,6 @@ private function removeSensitiveData($team)
'custom_server_limit',
'pivot',
]);
if (request()->attributes->get('can_read_sensitive', false) === false) {
$team->makeHidden([
'smtp_username',
'smtp_password',
'resend_api_key',
'telegram_token',
]);
}
return serializeApiResponse($team);
}

View file

@ -16,7 +16,9 @@
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Support\ValidationPatterns;
use Illuminate\Support\Facades\Process;
use Livewire\Attributes\Locked;
use Livewire\Component;
class GetLogs extends Component
@ -29,12 +31,16 @@ class GetLogs extends Component
public string $errors = '';
#[Locked]
public Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|null $resource = null;
#[Locked]
public ServiceApplication|ServiceDatabase|null $servicesubtype = null;
#[Locked]
public Server $server;
#[Locked]
public ?string $container = null;
public ?string $displayName = null;
@ -54,7 +60,7 @@ class GetLogs extends Component
public function mount()
{
if (! is_null($this->resource)) {
if ($this->resource->getMorphClass() === \App\Models\Application::class) {
if ($this->resource->getMorphClass() === Application::class) {
$this->showTimeStamps = $this->resource->settings->is_include_timestamps;
} else {
if ($this->servicesubtype) {
@ -63,7 +69,7 @@ public function mount()
$this->showTimeStamps = $this->resource->is_include_timestamps;
}
}
if ($this->resource?->getMorphClass() === \App\Models\Application::class) {
if ($this->resource?->getMorphClass() === Application::class) {
if (str($this->container)->contains('-pr-')) {
$this->pull_request = 'Pull Request: '.str($this->container)->afterLast('-pr-')->beforeLast('_')->value();
}
@ -74,11 +80,11 @@ public function mount()
public function instantSave()
{
if (! is_null($this->resource)) {
if ($this->resource->getMorphClass() === \App\Models\Application::class) {
if ($this->resource->getMorphClass() === Application::class) {
$this->resource->settings->is_include_timestamps = $this->showTimeStamps;
$this->resource->settings->save();
}
if ($this->resource->getMorphClass() === \App\Models\Service::class) {
if ($this->resource->getMorphClass() === Service::class) {
$serviceName = str($this->container)->beforeLast('-')->value();
$subType = $this->resource->applications()->where('name', $serviceName)->first();
if ($subType) {
@ -118,10 +124,20 @@ public function toggleStreamLogs()
public function getLogs($refresh = false)
{
if (! Server::ownedByCurrentTeam()->where('id', $this->server->id)->exists()) {
$this->outputs = 'Unauthorized.';
return;
}
if (! $this->server->isFunctional()) {
return;
}
if (! $refresh && ! $this->expandByDefault && ($this->resource?->getMorphClass() === \App\Models\Service::class || str($this->container)->contains('-pr-'))) {
if ($this->container && ! ValidationPatterns::isValidContainerName($this->container)) {
$this->outputs = 'Invalid container name.';
return;
}
if (! $refresh && ! $this->expandByDefault && ($this->resource?->getMorphClass() === Service::class || str($this->container)->contains('-pr-'))) {
return;
}
if ($this->numberOfLines <= 0 || is_null($this->numberOfLines)) {
@ -194,9 +210,15 @@ public function copyLogs(): string
public function downloadAllLogs(): string
{
if (! Server::ownedByCurrentTeam()->where('id', $this->server->id)->exists()) {
return '';
}
if (! $this->server->isFunctional() || ! $this->container) {
return '';
}
if (! ValidationPatterns::isValidContainerName($this->container)) {
return '';
}
if ($this->showTimeStamps) {
if ($this->server->isSwarm()) {

View file

@ -174,8 +174,11 @@ class Application extends BaseModel
'manual_webhook_secret_bitbucket',
'manual_webhook_secret_gitea',
'docker_compose_location',
'docker_compose_pr_location',
'docker_compose',
'docker_compose_pr',
'docker_compose_raw',
'docker_compose_pr_raw',
'docker_compose_domains',
'docker_compose_custom_start_command',
'docker_compose_custom_build_command',
@ -187,21 +190,19 @@ class Application extends BaseModel
'custom_nginx_configuration',
'custom_network_aliases',
'custom_healthcheck_found',
'nixpkgsarchive',
'is_http_basic_auth_enabled',
'http_basic_auth_username',
'http_basic_auth_password',
'connect_to_docker_network',
'force_domain_override',
'is_container_label_escape_enabled',
'use_build_server',
'config_hash',
'last_online_at',
'restart_count',
'last_restart_at',
'last_restart_type',
'environment_id',
'destination_id',
'destination_type',
'source_id',
'source_type',
'private_key_id',
'repository_project_id',
];
protected $appends = ['server_status'];
@ -1766,7 +1767,7 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory =
$fileList = collect([".$workdir$composeFile"]);
$gitRemoteStatus = $this->getGitRemoteStatus(deployment_uuid: $uuid);
if (! $gitRemoteStatus['is_accessible']) {
throw new RuntimeException("Failed to read Git source:\n\n{$gitRemoteStatus['error']}");
throw new RuntimeException('Failed to read Git source. Please verify repository access and try again.');
}
$getGitVersion = instant_remote_process(['git --version'], $this->destination->server, false);
$gitVersion = str($getGitVersion)->explode(' ')->last();
@ -1824,7 +1825,7 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory =
}
throw new RuntimeException('Repository does not exist. Please check your repository URL and try again.');
}
throw new RuntimeException($e->getMessage());
throw new RuntimeException('Failed to read the Docker Compose file from the repository.');
} finally {
// Cleanup only - restoration happens in catch block
$commands = collect([

View file

@ -99,6 +99,7 @@ class ServerSetting extends Model
];
protected $casts = [
'force_disabled' => 'boolean',
'force_docker_cleanup' => 'boolean',
'docker_cleanup_threshold' => 'integer',
'sentinel_token' => 'encrypted',

View file

@ -57,11 +57,6 @@ class Service extends BaseModel
'service_type',
'config_hash',
'compose_parsing_version',
'environment_id',
'server_id',
'destination_id',
'destination_type',
'is_container_label_escape_enabled',
];
protected $appends = ['server_status', 'status'];

View file

@ -37,15 +37,12 @@ class StandaloneClickhouse extends BaseModel
'last_restart_at',
'last_restart_type',
'last_online_at',
'public_port_timeout',
'custom_docker_run_options',
'clickhouse_db',
];
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
protected $casts = [
'clickhouse_password' => 'encrypted',
'clickhouse_admin_password' => 'encrypted',
'public_port_timeout' => 'integer',
'restart_count' => 'integer',
'last_restart_at' => 'datetime',

View file

@ -36,9 +36,6 @@ class StandaloneDragonfly extends BaseModel
'last_restart_at',
'last_restart_type',
'last_online_at',
'public_port_timeout',
'enable_ssl',
'custom_docker_run_options',
];
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];

View file

@ -37,9 +37,6 @@ class StandaloneKeydb extends BaseModel
'last_restart_at',
'last_restart_type',
'last_online_at',
'public_port_timeout',
'enable_ssl',
'custom_docker_run_options',
];
protected $appends = ['internal_db_url', 'external_db_url', 'server_status'];

View file

@ -39,10 +39,6 @@ class StandaloneMariadb extends BaseModel
'last_restart_at',
'last_restart_type',
'last_online_at',
'public_port_timeout',
'enable_ssl',
'is_log_drain_enabled',
'custom_docker_run_options',
];
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];

View file

@ -37,12 +37,6 @@ class StandaloneMongodb extends BaseModel
'last_restart_at',
'last_restart_type',
'last_online_at',
'public_port_timeout',
'enable_ssl',
'ssl_mode',
'is_log_drain_enabled',
'is_include_timestamps',
'custom_docker_run_options',
];
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];

View file

@ -38,12 +38,6 @@ class StandaloneMysql extends BaseModel
'last_restart_at',
'last_restart_type',
'last_online_at',
'public_port_timeout',
'enable_ssl',
'ssl_mode',
'is_log_drain_enabled',
'is_include_timestamps',
'custom_docker_run_options',
];
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];

View file

@ -40,12 +40,6 @@ class StandalonePostgresql extends BaseModel
'last_restart_at',
'last_restart_type',
'last_online_at',
'public_port_timeout',
'enable_ssl',
'ssl_mode',
'is_log_drain_enabled',
'is_include_timestamps',
'custom_docker_run_options',
];
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];

View file

@ -34,11 +34,6 @@ class StandaloneRedis extends BaseModel
'last_restart_at',
'last_restart_type',
'last_online_at',
'public_port_timeout',
'enable_ssl',
'is_log_drain_enabled',
'is_include_timestamps',
'custom_docker_run_options',
];
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];

View file

@ -43,6 +43,7 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
protected $fillable = [
'name',
'description',
'personal_team',
'show_boarding',
'custom_server_limit',
];

View file

@ -49,9 +49,6 @@ class User extends Authenticatable implements SendsEmail
'password',
'force_password_reset',
'marketing_emails',
'pending_email',
'email_change_code',
'email_change_code_expires_at',
];
protected $hidden = [
@ -98,7 +95,7 @@ protected static function boot()
$team['id'] = 0;
$team['name'] = 'Root Team';
}
$new_team = Team::create($team);
$new_team = Team::forceCreate($team);
$user->teams()->attach($new_team, ['role' => 'owner']);
});
@ -201,7 +198,7 @@ public function recreate_personal_team()
$team['id'] = 0;
$team['name'] = 'Root Team';
}
$new_team = Team::create($team);
$new_team = Team::forceCreate($team);
$this->teams()->attach($new_team, ['role' => 'owner']);
return $new_team;
@ -412,11 +409,11 @@ public function requestEmailChange(string $newEmail): void
$expiryMinutes = config('constants.email_change.verification_code_expiry_minutes', 10);
$expiresAt = Carbon::now()->addMinutes($expiryMinutes);
$this->update([
$this->forceFill([
'pending_email' => $newEmail,
'email_change_code' => $code,
'email_change_code_expires_at' => $expiresAt,
]);
])->save();
// Send verification email to new address
$this->notify(new EmailChangeVerification($this, $code, $newEmail, $expiresAt));

View file

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
class EncryptExistingClickhouseAdminPasswords extends Migration
{
public function up(): void
{
try {
DB::table('standalone_clickhouses')->chunkById(100, function ($clickhouses) {
foreach ($clickhouses as $clickhouse) {
$password = $clickhouse->clickhouse_admin_password;
if (empty($password)) {
continue;
}
// Skip if already encrypted (idempotent)
try {
Crypt::decryptString($password);
continue;
} catch (Exception) {
// Not encrypted yet — encrypt it
}
DB::table('standalone_clickhouses')
->where('id', $clickhouse->id)
->update(['clickhouse_admin_password' => Crypt::encryptString($password)]);
}
});
} catch (Exception $e) {
echo 'Encrypting ClickHouse admin passwords failed.';
echo $e->getMessage();
}
}
}

View file

@ -45,12 +45,13 @@ public function run(): void
}
try {
User::create([
$user = (new User)->forceFill([
'id' => 0,
'name' => env('ROOT_USERNAME', 'Root User'),
'email' => env('ROOT_USER_EMAIL'),
'password' => Hash::make(env('ROOT_USER_PASSWORD')),
]);
$user->save();
echo "\n SUCCESS Root user created successfully.\n\n";
} catch (\Exception $e) {
echo "\n ERROR Failed to create root user: {$e->getMessage()}\n\n";

View file

@ -0,0 +1,162 @@
<?php
use App\Livewire\Project\Shared\GetLogs;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Attributes\Locked;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'owner']);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
// Server::created auto-creates a StandaloneDocker, reuse it
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
$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,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
describe('GetLogs locked properties', function () {
test('container property has Locked attribute', function () {
$property = new ReflectionProperty(GetLogs::class, 'container');
$attributes = $property->getAttributes(Locked::class);
expect($attributes)->not->toBeEmpty();
});
test('server property has Locked attribute', function () {
$property = new ReflectionProperty(GetLogs::class, 'server');
$attributes = $property->getAttributes(Locked::class);
expect($attributes)->not->toBeEmpty();
});
test('resource property has Locked attribute', function () {
$property = new ReflectionProperty(GetLogs::class, 'resource');
$attributes = $property->getAttributes(Locked::class);
expect($attributes)->not->toBeEmpty();
});
test('servicesubtype property has Locked attribute', function () {
$property = new ReflectionProperty(GetLogs::class, 'servicesubtype');
$attributes = $property->getAttributes(Locked::class);
expect($attributes)->not->toBeEmpty();
});
});
describe('GetLogs Livewire action validation', function () {
test('getLogs rejects invalid container name', function () {
// Make server functional by setting settings directly
$this->server->settings->forceFill([
'is_reachable' => true,
'is_usable' => true,
'force_disabled' => false,
])->save();
// Reload server with fresh settings to ensure casted values
$server = Server::with('settings')->find($this->server->id);
Livewire::test(GetLogs::class, [
'server' => $server,
'resource' => $this->application,
'container' => 'container;malicious-command',
])
->call('getLogs')
->assertSet('outputs', 'Invalid container name.');
});
test('getLogs rejects unauthorized server access', function () {
$otherTeam = Team::factory()->create();
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
Livewire::test(GetLogs::class, [
'server' => $otherServer,
'resource' => $this->application,
'container' => 'test-container',
])
->call('getLogs')
->assertSet('outputs', 'Unauthorized.');
});
test('downloadAllLogs returns empty for invalid container name', function () {
$this->server->settings->forceFill([
'is_reachable' => true,
'is_usable' => true,
'force_disabled' => false,
])->save();
$server = Server::with('settings')->find($this->server->id);
Livewire::test(GetLogs::class, [
'server' => $server,
'resource' => $this->application,
'container' => 'container$(whoami)',
])
->call('downloadAllLogs')
->assertReturned('');
});
test('downloadAllLogs returns empty for unauthorized server', function () {
$otherTeam = Team::factory()->create();
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
Livewire::test(GetLogs::class, [
'server' => $otherServer,
'resource' => $this->application,
'container' => 'test-container',
])
->call('downloadAllLogs')
->assertReturned('');
});
});
describe('GetLogs container name injection payloads are blocked by validation', function () {
test('newline injection payload is rejected', function () {
// The exact PoC payload from the advisory
$payload = "postgresql 2>/dev/null\necho '===RCE-START==='\nid\nwhoami\nhostname\ncat /etc/hostname\necho '===RCE-END==='\n#";
expect(ValidationPatterns::isValidContainerName($payload))->toBeFalse();
});
test('semicolon injection payload is rejected', function () {
expect(ValidationPatterns::isValidContainerName('postgresql;id'))->toBeFalse();
});
test('backtick injection payload is rejected', function () {
expect(ValidationPatterns::isValidContainerName('postgresql`id`'))->toBeFalse();
});
test('command substitution injection payload is rejected', function () {
expect(ValidationPatterns::isValidContainerName('postgresql$(whoami)'))->toBeFalse();
});
test('pipe injection payload is rejected', function () {
expect(ValidationPatterns::isValidContainerName('postgresql|cat /etc/passwd'))->toBeFalse();
});
test('valid container names are accepted', function () {
expect(ValidationPatterns::isValidContainerName('postgresql'))->toBeTrue();
expect(ValidationPatterns::isValidContainerName('my-app-container'))->toBeTrue();
expect(ValidationPatterns::isValidContainerName('service_db.v2'))->toBeTrue();
expect(ValidationPatterns::isValidContainerName('coolify-proxy'))->toBeTrue();
});
});

View file

@ -48,19 +48,19 @@
}
});
test('Application model blocks mass assignment of identity fields', function () {
test('Application model blocks mass assignment of relationship IDs', function () {
$application = new Application;
$dangerousFields = ['id', 'uuid', 'environment_id', 'destination_id', 'destination_type', 'source_id', 'source_type', 'private_key_id', 'repository_project_id'];
expect($application->isFillable('id'))->toBeFalse('id should not be fillable');
expect($application->isFillable('uuid'))->toBeFalse('uuid should not be fillable');
expect($application->isFillable('created_at'))->toBeFalse('created_at should not be fillable');
expect($application->isFillable('updated_at'))->toBeFalse('updated_at should not be fillable');
expect($application->isFillable('deleted_at'))->toBeFalse('deleted_at should not be fillable');
foreach ($dangerousFields as $field) {
expect($application->isFillable($field))
->toBeFalse("Application model should not allow mass assignment of '{$field}'");
}
});
test('Application model allows mass assignment of user-facing fields', function () {
$application = new Application;
$userFields = ['name', 'description', 'git_repository', 'git_branch', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'health_check_path', 'health_check_enabled', 'limits_memory', 'status'];
$userFields = ['name', 'description', 'git_repository', 'git_branch', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'health_check_path', 'limits_memory', 'status'];
foreach ($userFields as $field) {
expect($application->isFillable($field))
@ -68,39 +68,21 @@
}
});
test('Application model allows mass assignment of relationship fields needed for create()', function () {
$application = new Application;
$relationFields = ['environment_id', 'destination_id', 'destination_type', 'source_id', 'source_type', 'private_key_id', 'repository_project_id'];
foreach ($relationFields as $field) {
expect($application->isFillable($field))
->toBeTrue("Application model should allow mass assignment of '{$field}' for internal create() calls");
}
});
test('Application fill ignores non-fillable fields', function () {
$application = new Application;
$application->fill([
'name' => 'test-app',
'team_id' => 999,
]);
expect($application->name)->toBe('test-app');
expect($application->team_id)->toBeNull();
});
test('Server model has $fillable and no conflicting $guarded', function () {
$server = new Server;
$fillable = $server->getFillable();
$guarded = $server->getGuarded();
expect($fillable)->not->toBeEmpty('Server model should have explicit $fillable');
// Guarded should be the default ['*'] when $fillable is set, not []
expect($guarded)->not->toBe([], 'Server model should not have $guarded = [] overriding $fillable');
});
test('Server model blocks mass assignment of dangerous fields', function () {
$server = new Server;
// These fields should not be mass-assignable via the API
expect($server->isFillable('id'))->toBeFalse();
expect($server->isFillable('uuid'))->toBeFalse();
expect($server->isFillable('created_at'))->toBeFalse();
@ -114,6 +96,9 @@
expect($user->isFillable('remember_token'))->toBeFalse('remember_token should not be fillable');
expect($user->isFillable('two_factor_secret'))->toBeFalse('two_factor_secret should not be fillable');
expect($user->isFillable('two_factor_recovery_codes'))->toBeFalse('two_factor_recovery_codes should not be fillable');
expect($user->isFillable('pending_email'))->toBeFalse('pending_email should not be fillable');
expect($user->isFillable('email_change_code'))->toBeFalse('email_change_code should not be fillable');
expect($user->isFillable('email_change_code_expires_at'))->toBeFalse('email_change_code_expires_at should not be fillable');
});
test('User model allows mass assignment of profile fields', function () {
@ -128,26 +113,21 @@
$team = new Team;
expect($team->isFillable('id'))->toBeFalse();
expect($team->isFillable('personal_team'))->toBeFalse('personal_team should not be fillable');
expect($team->isFillable('use_instance_email_settings'))->toBeFalse('use_instance_email_settings should not be fillable (migrated to EmailNotificationSettings)');
expect($team->isFillable('resend_api_key'))->toBeFalse('resend_api_key should not be fillable (migrated to EmailNotificationSettings)');
});
test('Service model blocks mass assignment of identity fields', function () {
$service = new Service;
test('Team model allows mass assignment of expected fields', function () {
$team = new Team;
expect($service->isFillable('id'))->toBeFalse();
expect($service->isFillable('uuid'))->toBeFalse();
expect($team->isFillable('name'))->toBeTrue();
expect($team->isFillable('description'))->toBeTrue();
expect($team->isFillable('personal_team'))->toBeTrue();
expect($team->isFillable('show_boarding'))->toBeTrue();
expect($team->isFillable('custom_server_limit'))->toBeTrue();
});
test('Service model allows mass assignment of relationship fields needed for create()', function () {
$service = new Service;
expect($service->isFillable('environment_id'))->toBeTrue();
expect($service->isFillable('destination_id'))->toBeTrue();
expect($service->isFillable('destination_type'))->toBeTrue();
expect($service->isFillable('server_id'))->toBeTrue();
});
test('standalone database models block mass assignment of identity and relationship fields', function () {
test('standalone database models block mass assignment of relationship IDs', function () {
$models = [
StandalonePostgresql::class,
StandaloneRedis::class,
@ -161,17 +141,12 @@
foreach ($models as $modelClass) {
$model = new $modelClass;
$dangerousFields = ['id', 'uuid', 'environment_id', 'destination_id', 'destination_type'];
expect($model->isFillable('id'))
->toBeFalse("{$modelClass} should not allow mass assignment of 'id'");
expect($model->isFillable('uuid'))
->toBeFalse("{$modelClass} should not allow mass assignment of 'uuid'");
expect($model->isFillable('environment_id'))
->toBeFalse("{$modelClass} should not allow mass assignment of 'environment_id'");
expect($model->isFillable('destination_id'))
->toBeFalse("{$modelClass} should not allow mass assignment of 'destination_id'");
expect($model->isFillable('destination_type'))
->toBeFalse("{$modelClass} should not allow mass assignment of 'destination_type'");
foreach ($dangerousFields as $field) {
expect($model->isFillable($field))
->toBeFalse("Model {$modelClass} should not allow mass assignment of '{$field}'");
}
}
});
@ -193,57 +168,29 @@
expect($model->isFillable('mongo_initdb_root_username'))->toBeTrue();
});
test('standalone database models allow mass assignment of public_port_timeout', function () {
$models = [
StandalonePostgresql::class,
StandaloneRedis::class,
StandaloneMysql::class,
StandaloneMariadb::class,
StandaloneMongodb::class,
StandaloneKeydb::class,
StandaloneDragonfly::class,
StandaloneClickhouse::class,
];
test('Application fill ignores non-fillable fields', function () {
$application = new Application;
$application->fill([
'name' => 'test-app',
'environment_id' => 999,
'destination_id' => 999,
'team_id' => 999,
'private_key_id' => 999,
]);
foreach ($models as $modelClass) {
$model = new $modelClass;
expect($model->isFillable('public_port_timeout'))
->toBeTrue("{$modelClass} should allow mass assignment of 'public_port_timeout'");
}
expect($application->name)->toBe('test-app');
expect($application->environment_id)->toBeNull();
expect($application->destination_id)->toBeNull();
expect($application->private_key_id)->toBeNull();
});
test('standalone database models allow mass assignment of SSL fields where applicable', function () {
// Models with enable_ssl
$sslModels = [
StandalonePostgresql::class,
StandaloneMysql::class,
StandaloneMariadb::class,
StandaloneMongodb::class,
StandaloneRedis::class,
StandaloneKeydb::class,
StandaloneDragonfly::class,
];
test('Service model blocks mass assignment of relationship IDs', function () {
$service = new Service;
foreach ($sslModels as $modelClass) {
$model = new $modelClass;
expect($model->isFillable('enable_ssl'))
->toBeTrue("{$modelClass} should allow mass assignment of 'enable_ssl'");
}
// Clickhouse has no SSL columns
expect((new StandaloneClickhouse)->isFillable('enable_ssl'))->toBeFalse();
// Models with ssl_mode
$sslModeModels = [
StandalonePostgresql::class,
StandaloneMysql::class,
StandaloneMongodb::class,
];
foreach ($sslModeModels as $modelClass) {
$model = new $modelClass;
expect($model->isFillable('ssl_mode'))
->toBeTrue("{$modelClass} should allow mass assignment of 'ssl_mode'");
}
expect($service->isFillable('id'))->toBeFalse();
expect($service->isFillable('uuid'))->toBeFalse();
expect($service->isFillable('environment_id'))->toBeFalse();
expect($service->isFillable('destination_id'))->toBeFalse();
expect($service->isFillable('server_id'))->toBeFalse();
});
});