Merge remote-tracking branch 'origin/next' into feat/search-to-envs

This commit is contained in:
Andras Bacsai 2026-06-03 12:47:37 +02:00
commit d300ddf902
16 changed files with 536 additions and 57 deletions

View file

@ -13,6 +13,7 @@
use App\Models\Project;
use App\Models\Server as ModelsServer;
use App\Rules\ValidServerIp;
use App\Support\ValidationPatterns;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
@ -487,10 +488,12 @@ public function create_server(Request $request)
'ip' => ['string', 'required', new ValidServerIp],
'port' => 'integer|nullable|between:1,65535',
'private_key_uuid' => 'string|required',
'user' => ['string', 'nullable', 'regex:/^[a-zA-Z0-9_-]+$/'],
'user' => ValidationPatterns::serverUsernameRules(required: false),
'is_build_server' => 'boolean|nullable',
'instant_validate' => 'boolean|nullable',
'proxy_type' => 'string|nullable',
], [
...ValidationPatterns::serverUsernameMessages(),
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
@ -666,7 +669,7 @@ public function update_server(Request $request)
'ip' => ['string', 'nullable', new ValidServerIp],
'port' => 'integer|nullable|between:1,65535',
'private_key_uuid' => 'string|nullable',
'user' => ['string', 'nullable', 'regex:/^[a-zA-Z0-9_-]+$/'],
'user' => ValidationPatterns::serverUsernameRules(required: false),
'is_build_server' => 'boolean|nullable',
'instant_validate' => 'boolean|nullable',
'proxy_type' => 'string|nullable',
@ -676,6 +679,8 @@ public function update_server(Request $request)
'server_disk_usage_notification_threshold' => 'integer|min:1|max:100',
'server_disk_usage_check_frequency' => 'string',
'connection_timeout' => 'integer|min:1|max:300',
], [
...ValidationPatterns::serverUsernameMessages(),
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);

View file

@ -8,6 +8,7 @@
use App\Models\Server;
use App\Models\Team;
use App\Services\ConfigurationRepository;
use App\Support\ValidationPatterns;
use Illuminate\Support\Collection;
use Livewire\Attributes\Url;
use Livewire\Component;
@ -212,6 +213,23 @@ private function updateServerDetails()
}
}
protected function rules(): array
{
return [
'remoteServerName' => 'required|string',
'remoteServerHost' => 'required|string',
'remoteServerPort' => 'required|integer|min:1|max:65535',
'remoteServerUser' => ValidationPatterns::serverUsernameRules(),
];
}
protected function messages(): array
{
return [
...ValidationPatterns::serverUsernameMessages('remoteServerUser', 'SSH User'),
];
}
public function getProxyType()
{
$this->selectProxy(ProxyTypes::TRAEFIK->value);
@ -274,12 +292,7 @@ public function savePrivateKey()
public function saveServer()
{
$this->validate([
'remoteServerName' => 'required|string',
'remoteServerHost' => 'required|string',
'remoteServerPort' => 'required|integer',
'remoteServerUser' => 'required|string',
]);
$this->validate();
$this->privateKey = formatPrivateKey($this->privateKey);
$foundServer = Server::whereIp($this->remoteServerHost)->first();
@ -465,10 +478,10 @@ public function showNewResource()
public function saveAndValidateServer()
{
$this->validate([
'remoteServerPort' => 'required|integer|min:1|max:65535',
'remoteServerUser' => 'required|string',
]);
$this->validate(array_intersect_key($this->rules(), array_flip([
'remoteServerPort',
'remoteServerUser',
])));
$this->createdServer->update([
'port' => $this->remoteServerPort,

View file

@ -57,7 +57,7 @@ protected function rules(): array
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'ip' => ['required', 'string', new ValidServerIp],
'user' => ['required', 'string', 'regex:/^[a-zA-Z0-9_-]+$/'],
'user' => ValidationPatterns::serverUsernameRules(),
'port' => 'required|integer|between:1,65535',
'is_build_server' => 'required|boolean',
];
@ -75,6 +75,7 @@ protected function messages(): array
'ip.string' => 'The IP Address/Domain must be a string.',
'user.required' => 'The User field is required.',
'user.string' => 'The User field must be a string.',
...ValidationPatterns::serverUsernameMessages(),
'port.required' => 'The Port field is required.',
'port.integer' => 'The Port field must be an integer.',
'port.between' => 'The Port field must be between 1 and 65535.',

View file

@ -110,7 +110,7 @@ protected function rules(): array
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'ip' => ['required', new ValidServerIp],
'user' => ['required', 'regex:/^[a-zA-Z0-9_-]+$/'],
'user' => ValidationPatterns::serverUsernameRules(),
'port' => 'required|integer|between:1,65535',
'connectionTimeout' => 'required|integer|min:1|max:300',
'validationLogs' => 'nullable',
@ -140,6 +140,7 @@ protected function messages(): array
[
'ip.required' => 'The IP Address field is required.',
'user.required' => 'The User field is required.',
...ValidationPatterns::serverUsernameMessages(),
'port.required' => 'The Port field is required.',
'connectionTimeout.required' => 'The SSH Connection Timeout field is required.',
'connectionTimeout.integer' => 'The SSH Connection Timeout must be an integer.',

View file

@ -8,6 +8,15 @@
use App\Models\ScheduledTask;
use App\Models\ScheduledTaskExecution;
use App\Models\Server;
use App\Models\ServiceDatabase;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Services\SchedulerLogParser;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
@ -125,7 +134,21 @@ private function enrichSkipLogsWithLinks(Collection $skipLogs): Collection
: collect();
$backups = $backupIds->isNotEmpty()
? ScheduledDatabaseBackup::with(['database.environment.project'])->whereIn('id', $backupIds)->get()->keyBy('id')
? ScheduledDatabaseBackup::with('database')
->whereIn('id', $backupIds)
->get()
->loadMorph('database', [
ServiceDatabase::class => ['service.environment.project'],
StandaloneClickhouse::class => ['environment.project'],
StandaloneDragonfly::class => ['environment.project'],
StandaloneKeydb::class => ['environment.project'],
StandaloneMariadb::class => ['environment.project'],
StandaloneMongodb::class => ['environment.project'],
StandaloneMysql::class => ['environment.project'],
StandalonePostgresql::class => ['environment.project'],
StandaloneRedis::class => ['environment.project'],
])
->keyBy('id')
: collect();
$servers = $serverIds->isNotEmpty()
@ -161,14 +184,29 @@ private function enrichSkipLogsWithLinks(Collection $skipLogs): Collection
if ($backup) {
$database = $backup->database;
$skip['resource_name'] = $database?->name ?? 'Database backup';
$environment = $database?->environment;
$project = $environment?->project;
if ($project && $environment && $database) {
$skip['link'] = route('project.database.backup.index', [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'database_uuid' => $database->uuid,
]);
if ($database instanceof ServiceDatabase) {
$service = $database->service;
$environment = $service?->environment;
$project = $environment?->project;
if ($project && $environment && $service) {
$skip['link'] = route('project.service.database.backups', [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'service_uuid' => $service->uuid,
'stack_service_uuid' => $database->uuid,
]);
}
} else {
$environment = $database?->environment;
$project = $environment?->project;
if ($project && $environment && $database) {
$skip['link'] = route('project.database.backup.index', [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'database_uuid' => $database->uuid,
]);
}
}
}
} elseif ($skip['type'] === 'docker_cleanup') {

View file

@ -17,6 +17,7 @@
use App\Notifications\Server\Reachable;
use App\Notifications\Server\Unreachable;
use App\Services\ConfigurationRepository;
use App\Support\ValidationPatterns;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
@ -945,10 +946,10 @@ public function user(): Attribute
{
return Attribute::make(
get: function ($value) {
return preg_replace('/[^A-Za-z0-9\-_]/', '', $value);
return preg_replace(ValidationPatterns::INVALID_SERVER_USERNAME_CHARACTERS_PATTERN, '', $value);
},
set: function ($value) {
return preg_replace('/[^A-Za-z0-9\-_]/', '', $value);
return preg_replace(ValidationPatterns::INVALID_SERVER_USERNAME_CHARACTERS_PATTERN, '', $value);
}
);
}

View file

@ -35,6 +35,17 @@ class ValidationPatterns
*/
public const DOCKER_TARGET_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/';
/**
* Pattern for SSH usernames.
* Allows alphanumeric characters, dots, hyphens, and underscores.
*/
public const SERVER_USERNAME_PATTERN = '/^[a-zA-Z0-9._-]+$/';
/**
* Pattern for removing characters not allowed in SSH usernames.
*/
public const INVALID_SERVER_USERNAME_CHARACTERS_PATTERN = '/[^A-Za-z0-9.\-_]/';
/**
* Token-aware pattern for shell-safe command strings (docker compose commands, docker run options).
*
@ -283,6 +294,28 @@ public static function databaseIdentifierRules(bool $required = true, int $minLe
return $rules;
}
/**
* Get validation rules for SSH username fields.
*/
public static function serverUsernameRules(bool $required = true): array
{
return [
$required ? 'required' : 'nullable',
'string',
'regex:'.self::SERVER_USERNAME_PATTERN,
];
}
/**
* Get validation messages for SSH username fields.
*/
public static function serverUsernameMessages(string $field = 'user', string $label = 'User'): array
{
return [
"{$field}.regex" => "The {$label} may only contain letters, numbers, dots, hyphens, and underscores.",
];
}
/**
* Get validation messages for database identifier fields.
*/

View file

@ -1865,15 +1865,15 @@ function isBase64Encoded($strValue)
{
return base64_encode(base64_decode($strValue, true)) === $strValue;
}
function customApiValidator(Collection|array $item, array $rules)
function customApiValidator(Collection|array $item, array $rules, array $messages = [])
{
if (is_array($item)) {
$item = collect($item);
}
return Validator::make($item->toArray(), $rules, [
return Validator::make($item->toArray(), $rules, array_merge([
'required' => 'This field is required.',
]);
], $messages));
}
function parseDockerComposeFile(Service|Application $resource, bool $isNew = false, int $pull_request_id = 0, ?int $preview_id = null)
{

View file

@ -36,32 +36,34 @@ class="relative w-auto h-auto" wire:ignore>
<template x-teleport="body">
<div x-show="modalOpen"
x-init="$watch('modalOpen', value => { if(value) { $nextTick(() => { const firstInput = $el.querySelector('input, textarea, select'); firstInput?.focus(); }) } })"
class="fixed top-0 left-0 z-99 flex items-center justify-center w-screen h-screen p-4">
class="fixed inset-0 z-99 overflow-y-auto">
<div x-show="modalOpen" x-transition:enter="ease-out duration-100" x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
@if ($closeOutside) @click="modalOpen=false" @endif
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
<div id="{{ $modalId }}" x-show="modalOpen" x-trap.inert.noscroll="modalOpen"
x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
class="relative w-full lg:w-auto lg:min-w-2xl lg:max-w-4xl border rounded-sm drop-shadow-sm bg-white border-neutral-200 dark:bg-base dark:border-coolgray-300 flex flex-col">
<div class="flex items-center justify-between py-6 px-6 shrink-0">
<h3 class="text-2xl font-bold">{{ $title }}</h3>
<button @click="modalOpen=false"
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base">
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="relative flex items-center justify-center w-auto px-6 pb-6">
{{ $slot }}
<div @if ($closeOutside) @click.self="modalOpen=false" @endif class="relative flex min-h-full items-start justify-center p-4 sm:items-center">
<div id="{{ $modalId }}" x-show="modalOpen" x-trap.inert.noscroll="modalOpen"
x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
class="relative flex max-h-[calc(100dvh-2rem)] w-full flex-col overflow-hidden rounded-sm border border-neutral-200 bg-white drop-shadow-sm dark:border-coolgray-300 dark:bg-base lg:w-auto lg:min-w-2xl lg:max-w-4xl">
<div class="flex items-center justify-between py-6 px-6 shrink-0">
<h3 class="text-2xl font-bold">{{ $title }}</h3>
<button @click="modalOpen=false"
class="absolute cursor-pointer top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base">
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="relative min-h-0 flex-1 overflow-y-auto px-6 pb-6 pt-1"
style="-webkit-overflow-scrolling: touch;">
{{ $slot }}
</div>
</div>
</div>
</div>

View file

@ -32,11 +32,12 @@ class="flex flex-col h-full py-6 overflow-hidden border-l shadow-lg bg-neutral-5
<div class="px-4 pb-4 sm:px-5">
<div class="flex items-start justify-between pb-1">
<h2 class="text-2xl leading-6" id="slide-over-title">
{{ $title }}</h2>
{{ $title }}
</h2>
<div class="flex items-center h-auto ml-3">
<button @click="slideOverOpen=false"
class="absolute top-0 right-0 z-30 flex items-center justify-center px-3 py-2 mt-4 mr-2 space-x-1 text-xs font-normal border-none rounded-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" fill="none"
class="absolute cursor-pointer top-0 right-0 z-30 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none"
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M6 18L18 6M6 6l12 12"></path>

View file

@ -186,8 +186,15 @@
copyLogs() {
const content = this.collectVisibleLogs();
if (!content) return;
navigator.clipboard.writeText(content);
Livewire.dispatch('success', ['Logs copied to clipboard.']);
if (!navigator.clipboard?.writeText) {
Livewire.dispatch('error', ['Clipboard is not available. Please use HTTPS or localhost.']);
return;
}
navigator.clipboard?.writeText(content).then(() => {
Livewire.dispatch('success', ['Logs copied to clipboard.']);
}).catch(() => {
Livewire.dispatch('error', ['Failed to copy logs to clipboard.']);
});
},
downloadLogs() {
const content = this.collectVisibleLogs();

View file

@ -346,8 +346,17 @@ class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-
<button
x-on:click="
$wire.copyLogs().then(logs => {
navigator.clipboard.writeText(logs);
Livewire.dispatch('success', ['Logs copied to clipboard.']);
if (!navigator.clipboard?.writeText) {
Livewire.dispatch('error', ['Clipboard is not available. Please use HTTPS or localhost.']);
return;
}
navigator.clipboard.writeText(logs).then(() => {
Livewire.dispatch('success', ['Logs copied to clipboard.']);
}).catch(() => {
Livewire.dispatch('error', ['Failed to copy logs to clipboard.']);
});
}).catch(() => {
Livewire.dispatch('error', ['Failed to prepare logs for clipboard.']);
});
"
title="Copy Logs"

View file

@ -0,0 +1,39 @@
<?php
use Illuminate\Support\Str;
function bladeView(string $path): string
{
return file_get_contents(base_path($path));
}
it('guards deployment log clipboard writes and reports promise failures', function () {
$view = bladeView('resources/views/livewire/project/application/deployment/show.blade.php');
expect($view)
->toContain('copyLogs()')
->toContain('navigator.clipboard?.writeText')
->toContain("Livewire.dispatch('error', ['Clipboard is not available. Please use HTTPS or localhost.']);")
->toContain("Livewire.dispatch('error', ['Failed to copy logs to clipboard.']);")
->toContain("Livewire.dispatch('success', ['Logs copied to clipboard.']);");
expect(Str::between($view, 'copyLogs() {', 'downloadLogs()'))
->toContain('navigator.clipboard?.writeText(content).then(() =>')
->not->toContain("navigator.clipboard.writeText(content);\n Livewire.dispatch('success'");
});
it('guards shared log clipboard writes and handles Livewire preparation failures', function () {
$view = bladeView('resources/views/livewire/project/shared/get-logs.blade.php');
expect($view)
->toContain('navigator.clipboard?.writeText')
->toContain("Livewire.dispatch('error', ['Clipboard is not available. Please use HTTPS or localhost.']);")
->toContain("Livewire.dispatch('error', ['Failed to copy logs to clipboard.']);")
->toContain("Livewire.dispatch('error', ['Failed to prepare logs for clipboard.']);")
->toContain("Livewire.dispatch('success', ['Logs copied to clipboard.']);");
expect($view)
->toContain('$wire.copyLogs().then(logs =>')
->toContain('}).catch(() => {')
->not->toContain('navigator.clipboard.writeText(logs);');
});

View file

@ -2,9 +2,15 @@
use App\Livewire\Settings\ScheduledJobs;
use App\Models\DockerCleanupExecution;
use App\Models\Environment;
use App\Models\Project;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceDatabase;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use App\Services\SchedulerLogParser;
@ -13,6 +19,35 @@
uses(RefreshDatabase::class);
function withIsolatedScheduledLogsForMonitoringTest(callable $callback): mixed
{
$logDir = storage_path('logs');
if (! is_dir($logDir)) {
mkdir($logDir, 0755, true);
}
$renamed = [];
foreach (glob($logDir.'/scheduled-*.log') as $log) {
$tmp = $log.'.scheduled-jobs-test-bak';
rename($log, $tmp);
$renamed[$tmp] = $log;
}
try {
return $callback($logDir.'/scheduled-'.now()->format('Y-m-d').'.log');
} finally {
foreach (glob($logDir.'/scheduled-*.log') as $log) {
@unlink($log);
}
foreach ($renamed as $tmp => $original) {
if (file_exists($tmp)) {
rename($tmp, $original);
}
}
}
}
beforeEach(function () {
// Create root team (id 0) and root user
$this->rootTeam = Team::factory()->create(['id' => 0, 'name' => 'Root Team']);
@ -270,3 +305,96 @@
rename($tmp, $original);
}
});
test('skipped service database backups render with service backup link', function () {
$this->actingAs($this->rootUser);
session(['currentTeam' => $this->rootTeam]);
$server = Server::factory()->create(['team_id' => $this->rootTeam->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $this->rootTeam->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$service = Service::factory()->create([
'server_id' => $server->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'environment_id' => $environment->id,
]);
$serviceDatabase = ServiceDatabase::create([
'service_id' => $service->id,
'name' => 'service-postgres',
'image' => 'postgres:16-alpine',
'custom_type' => 'postgresql',
]);
$backup = ScheduledDatabaseBackup::create([
'team_id' => $this->rootTeam->id,
'frequency' => '0 * * * *',
'database_id' => $serviceDatabase->id,
'database_type' => $serviceDatabase->getMorphClass(),
'enabled' => true,
]);
withIsolatedScheduledLogsForMonitoringTest(function (string $logPath) use ($backup, $project, $environment, $service, $serviceDatabase) {
file_put_contents(
$logPath,
'['.now()->format('Y-m-d H:i:s').'] production.INFO: Backup skipped {"type":"backup","skip_reason":"server_not_functional","backup_id":'.$backup->id.',"team_id":'.$this->rootTeam->id.'}'."\n"
);
$expectedUrl = route('project.service.database.backups', [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'service_uuid' => $service->uuid,
'stack_service_uuid' => $serviceDatabase->uuid,
]);
Livewire::test(ScheduledJobs::class)
->assertOk()
->assertSee('service-postgres')
->assertSeeHtml('href="'.$expectedUrl.'"');
});
});
test('skipped standalone database backups keep standalone backup link', function () {
$this->actingAs($this->rootUser);
session(['currentTeam' => $this->rootTeam]);
$server = Server::factory()->create(['team_id' => $this->rootTeam->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $this->rootTeam->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$database = StandalonePostgresql::create([
'name' => 'standalone-postgres',
'image' => 'postgres:16-alpine',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'postgres',
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
$backup = ScheduledDatabaseBackup::create([
'team_id' => $this->rootTeam->id,
'frequency' => '0 * * * *',
'database_id' => $database->id,
'database_type' => $database->getMorphClass(),
'enabled' => true,
]);
withIsolatedScheduledLogsForMonitoringTest(function (string $logPath) use ($backup, $project, $environment, $database) {
file_put_contents(
$logPath,
'['.now()->format('Y-m-d H:i:s').'] production.INFO: Backup skipped {"type":"backup","skip_reason":"server_not_functional","backup_id":'.$backup->id.',"team_id":'.$this->rootTeam->id.'}'."\n"
);
$expectedUrl = route('project.database.backup.index', [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'database_uuid' => $database->uuid,
]);
Livewire::test(ScheduledJobs::class)
->assertOk()
->assertSee('standalone-postgres')
->assertSeeHtml('href="'.$expectedUrl.'"');
});
});

View file

@ -0,0 +1,182 @@
<?php
use App\Livewire\Boarding\Index as BoardingIndex;
use App\Livewire\Server\New\ByIp;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Visus\Cuid2\Cuid2;
uses(RefreshDatabase::class);
beforeEach(function () {
config(['app.maintenance.driver' => 'file']);
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->privateKey = PrivateKey::withoutEvents(fn () => PrivateKey::forceCreate([
'uuid' => (string) new Cuid2,
'name' => 'Test SSH Key',
'description' => 'Test SSH Key',
'private_key' => 'test-private-key',
'team_id' => $this->team->id,
]));
$token = $this->user->createToken('write-token', ['write']);
$token->accessToken->forceFill(['team_id' => $this->team->id])->save();
$this->token = $token->plainTextToken;
});
it('creates a server through the API with a dotted SSH username', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->token,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers', [
'name' => 'Dotted User Server',
'ip' => '192.0.2.10',
'private_key_uuid' => $this->privateKey->uuid,
'user' => 'deploy.user',
]);
$response->assertCreated();
$this->assertDatabaseHas('servers', [
'ip' => '192.0.2.10',
'user' => 'deploy.user',
]);
});
it('updates a server through the API with a dotted SSH username', function () {
$server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
'user' => 'deploy',
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->token,
'Content-Type' => 'application/json',
])->patchJson('/api/v1/servers/'.$server->uuid, [
'user' => 'deploy.user',
]);
$response->assertStatus(201);
expect($server->fresh()->user)->toBe('deploy.user');
});
it('rejects unsafe SSH usernames when creating a server through the API', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->token,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers', [
'name' => 'Unsafe User Server',
'ip' => '192.0.2.11',
'private_key_uuid' => $this->privateKey->uuid,
'user' => 'deploy$user',
]);
$response->assertStatus(422);
$response->assertJsonPath('errors.user.0', 'The User may only contain letters, numbers, dots, hyphens, and underscores.');
});
it('rejects unsafe SSH usernames through the API', function () {
$server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->token,
'Content-Type' => 'application/json',
])->patchJson('/api/v1/servers/'.$server->uuid, [
'user' => 'deploy$user',
]);
$response->assertStatus(422);
$response->assertJsonStructure(['errors' => ['user']]);
$response->assertJsonPath('errors.user.0', 'The User may only contain letters, numbers, dots, hyphens, and underscores.');
});
it('allows dotted SSH usernames in the server creation form', function () {
$this->actingAs($this->user);
Livewire::test(ByIp::class, [
'private_keys' => collect([$this->privateKey]),
'limit_reached' => false,
])
->set('name', 'Dotted User Server')
->set('ip', '192.0.2.20')
->set('user', 'deploy.user')
->set('private_key_id', $this->privateKey->id)
->call('submit')
->assertHasNoErrors(['user']);
$this->assertDatabaseHas('servers', [
'ip' => '192.0.2.20',
'user' => 'deploy.user',
]);
});
it('rejects unsafe SSH usernames in the server creation form', function () {
$this->actingAs($this->user);
Livewire::test(ByIp::class, [
'private_keys' => collect([$this->privateKey]),
'limit_reached' => false,
])
->set('name', 'Unsafe User Server')
->set('ip', '192.0.2.21')
->set('user', 'deploy$user')
->set('private_key_id', $this->privateKey->id)
->call('submit')
->assertHasErrors(['user' => ['regex']]);
});
it('rejects unsafe SSH usernames during onboarding server creation', function () {
$this->actingAs($this->user);
Livewire::test(BoardingIndex::class)
->set('createdPrivateKey', $this->privateKey)
->set('remoteServerName', 'Unsafe User Server')
->set('remoteServerHost', '192.0.2.30')
->set('remoteServerPort', 22)
->set('remoteServerUser', 'deploy$user')
->call('saveServer')
->assertHasErrors([
'remoteServerUser' => [
'regex',
'The SSH User may only contain letters, numbers, dots, hyphens, and underscores.',
],
]);
});
it('rejects unsafe SSH usernames during onboarding server validation', function () {
$this->actingAs($this->user);
$server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
'user' => 'deploy',
]);
Livewire::test(BoardingIndex::class)
->set('createdServer', $server)
->set('remoteServerPort', 22)
->set('remoteServerUser', 'deploy$user')
->call('saveAndValidateServer')
->assertHasErrors([
'remoteServerUser' => [
'regex',
'The SSH User may only contain letters, numbers, dots, hyphens, and underscores.',
],
]);
});

View file

@ -0,0 +1,19 @@
<?php
use App\Models\Server;
use App\Support\ValidationPatterns;
it('provides shared validation rules for SSH usernames', function () {
expect(ValidationPatterns::SERVER_USERNAME_PATTERN)->toBe('/^[a-zA-Z0-9._-]+$/');
expect(ValidationPatterns::serverUsernameRules())->toContain('regex:'.ValidationPatterns::SERVER_USERNAME_PATTERN);
expect(preg_match(ValidationPatterns::SERVER_USERNAME_PATTERN, 'deploy.user'))->toBe(1);
expect(preg_match(ValidationPatterns::SERVER_USERNAME_PATTERN, 'deploy$user'))->toBe(0);
});
it('preserves dots when sanitizing server SSH usernames', function () {
$server = new Server;
$server->user = 'deploy.user';
expect($server->user)->toBe('deploy.user');
});