Merge branch 'next' into v5.x-chore/deprecate-docker-swarm
This commit is contained in:
commit
d392bab4c1
41 changed files with 1700 additions and 255 deletions
|
|
@ -48,7 +48,7 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $
|
|||
);
|
||||
|
||||
$commands = [
|
||||
'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true"',
|
||||
'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true" --filter "label!=coolify.type=database" --filter "label!=coolify.type=application" --filter "label!=coolify.type=service"',
|
||||
$imagePruneCmd,
|
||||
'docker builder prune -af',
|
||||
"docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Application\CleanupPreviewDeployment;
|
||||
use App\Actions\Application\LoadComposeFile;
|
||||
use App\Actions\Application\StopApplication;
|
||||
use App\Actions\Service\StartService;
|
||||
|
|
@ -9,6 +10,7 @@
|
|||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\DeleteResourceJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\LocalFileVolume;
|
||||
|
|
@ -1058,7 +1060,7 @@ private function create_application(Request $request, $type)
|
|||
$connectToDockerNetwork = $request->connect_to_docker_network;
|
||||
$customNginxConfiguration = $request->custom_nginx_configuration;
|
||||
$isContainerLabelEscapeEnabled = $request->boolean('is_container_label_escape_enabled', true);
|
||||
$isPreserveRepositoryEnabled = $request->boolean('is_preserve_repository_enabled',false);
|
||||
$isPreserveRepositoryEnabled = $request->boolean('is_preserve_repository_enabled', false);
|
||||
|
||||
if (! is_null($customNginxConfiguration)) {
|
||||
if (! isBase64Encoded($customNginxConfiguration)) {
|
||||
|
|
@ -4474,4 +4476,73 @@ public function delete_storage(Request $request): JsonResponse
|
|||
|
||||
return response()->json(['message' => 'Storage deleted.']);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete Preview Deployment',
|
||||
description: 'Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes/networks, and deletes the preview record.',
|
||||
path: '/applications/{uuid}/previews/{pull_request_id}',
|
||||
operationId: 'delete-preview-deployment-by-pull-request-id',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the application.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'pull_request_id',
|
||||
in: 'path',
|
||||
description: 'Pull request ID of the preview to delete.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'integer')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Preview deletion queued.', content: new OA\JsonContent(
|
||||
properties: [new OA\Property(property: 'message', type: 'string')],
|
||||
)),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
public function delete_preview_by_pull_request_id(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
|
||||
if (! $application) {
|
||||
return response()->json(['message' => 'Application not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('delete', $application);
|
||||
|
||||
$pullRequestIdRaw = $request->route('pull_request_id');
|
||||
if (! is_numeric($pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0) {
|
||||
return response()->json(['message' => 'Invalid pull_request_id.'], 422);
|
||||
}
|
||||
$pullRequestId = (int) $pullRequestIdRaw;
|
||||
|
||||
$preview = ApplicationPreview::where('application_id', $application->id)
|
||||
->where('pull_request_id', $pullRequestId)
|
||||
->first();
|
||||
|
||||
if (! $preview) {
|
||||
return response()->json(['message' => 'Preview not found.'], 404);
|
||||
}
|
||||
|
||||
$preview->delete();
|
||||
CleanupPreviewDeployment::run($application, $pullRequestId, $preview);
|
||||
|
||||
return response()->json(['message' => 'Preview deletion request queued.']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -747,7 +747,7 @@ public function create_backup(Request $request)
|
|||
}
|
||||
|
||||
if ($request->filled('s3_storage_uuid')) {
|
||||
$existsInTeam = S3Storage::ownedByCurrentTeam()->where('uuid', $request->s3_storage_uuid)->exists();
|
||||
$existsInTeam = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->s3_storage_uuid)->exists();
|
||||
if (! $existsInTeam) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
|
|
@ -774,7 +774,7 @@ public function create_backup(Request $request)
|
|||
|
||||
// Convert s3_storage_uuid to s3_storage_id
|
||||
if (isset($backupData['s3_storage_uuid'])) {
|
||||
$s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first();
|
||||
$s3Storage = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $backupData['s3_storage_uuid'])->first();
|
||||
if ($s3Storage) {
|
||||
$backupData['s3_storage_id'] = $s3Storage->id;
|
||||
} elseif ($request->boolean('save_s3')) {
|
||||
|
|
@ -982,7 +982,7 @@ public function update_backup(Request $request)
|
|||
], 422);
|
||||
}
|
||||
if ($request->filled('s3_storage_uuid')) {
|
||||
$existsInTeam = S3Storage::ownedByCurrentTeam()->where('uuid', $request->s3_storage_uuid)->exists();
|
||||
$existsInTeam = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->s3_storage_uuid)->exists();
|
||||
if (! $existsInTeam) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
|
|
@ -1015,7 +1015,7 @@ public function update_backup(Request $request)
|
|||
|
||||
// Convert s3_storage_uuid to s3_storage_id
|
||||
if (isset($backupData['s3_storage_uuid'])) {
|
||||
$s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first();
|
||||
$s3Storage = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $backupData['s3_storage_uuid'])->first();
|
||||
if ($s3Storage) {
|
||||
$backupData['s3_storage_id'] = $s3Storage->id;
|
||||
} elseif ($request->boolean('save_s3')) {
|
||||
|
|
@ -1766,7 +1766,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
}
|
||||
$request->offsetSet('postgres_conf', $postgresConf);
|
||||
}
|
||||
$database = create_standalone_postgresql($environment->id, $destination->uuid, $request->only($allowedFields));
|
||||
$database = create_standalone_postgresql($environment->id, $destination, $request->only($allowedFields));
|
||||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
|
|
@ -1821,7 +1821,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
}
|
||||
$request->offsetSet('mariadb_conf', $mariadbConf);
|
||||
}
|
||||
$database = create_standalone_mariadb($environment->id, $destination->uuid, $request->only($allowedFields));
|
||||
$database = create_standalone_mariadb($environment->id, $destination, $request->only($allowedFields));
|
||||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
|
|
@ -1880,7 +1880,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
}
|
||||
$request->offsetSet('mysql_conf', $mysqlConf);
|
||||
}
|
||||
$database = create_standalone_mysql($environment->id, $destination->uuid, $request->only($allowedFields));
|
||||
$database = create_standalone_mysql($environment->id, $destination, $request->only($allowedFields));
|
||||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
|
|
@ -1936,7 +1936,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
}
|
||||
$request->offsetSet('redis_conf', $redisConf);
|
||||
}
|
||||
$database = create_standalone_redis($environment->id, $destination->uuid, $request->only($allowedFields));
|
||||
$database = create_standalone_redis($environment->id, $destination, $request->only($allowedFields));
|
||||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
|
|
@ -1973,7 +1973,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
}
|
||||
|
||||
removeUnnecessaryFieldsFromRequest($request);
|
||||
$database = create_standalone_dragonfly($environment->id, $destination->uuid, $request->only($allowedFields));
|
||||
$database = create_standalone_dragonfly($environment->id, $destination, $request->only($allowedFields));
|
||||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
|
|
@ -2022,7 +2022,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
}
|
||||
$request->offsetSet('keydb_conf', $keydbConf);
|
||||
}
|
||||
$database = create_standalone_keydb($environment->id, $destination->uuid, $request->only($allowedFields));
|
||||
$database = create_standalone_keydb($environment->id, $destination, $request->only($allowedFields));
|
||||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
|
|
@ -2058,7 +2058,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
], 422);
|
||||
}
|
||||
removeUnnecessaryFieldsFromRequest($request);
|
||||
$database = create_standalone_clickhouse($environment->id, $destination->uuid, $request->only($allowedFields));
|
||||
$database = create_standalone_clickhouse($environment->id, $destination, $request->only($allowedFields));
|
||||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
|
|
@ -2116,7 +2116,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
}
|
||||
$request->offsetSet('mongo_conf', $mongoConf);
|
||||
}
|
||||
$database = create_standalone_mongodb($environment->id, $destination->uuid, $request->only($allowedFields));
|
||||
$database = create_standalone_mongodb($environment->id, $destination, $request->only($allowedFields));
|
||||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -147,11 +147,15 @@ public function disable_api(Request $request)
|
|||
|
||||
public function feedback(Request $request)
|
||||
{
|
||||
$content = $request->input('content');
|
||||
$data = $request->validate([
|
||||
'content' => ['required', 'string', 'min:10', 'max:2000'],
|
||||
]);
|
||||
|
||||
$webhook_url = config('constants.webhooks.feedback_discord_webhook');
|
||||
if ($webhook_url) {
|
||||
Http::post($webhook_url, [
|
||||
'content' => $content,
|
||||
Http::timeout(5)->post($webhook_url, [
|
||||
'content' => $data['content'],
|
||||
'allowed_mentions' => ['parse' => []],
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -57,10 +57,29 @@ public function manual(Request $request)
|
|||
}
|
||||
foreach ($applications as $application) {
|
||||
$webhook_secret = data_get($application, 'manual_webhook_secret_bitbucket');
|
||||
if (empty($webhook_secret)) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'Webhook secret not configured.',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
$payload = $request->getContent();
|
||||
|
||||
[$algo, $hash] = explode('=', $x_bitbucket_token, 2);
|
||||
$payloadHash = hash_hmac($algo, $payload, $webhook_secret);
|
||||
$parts = explode('=', $x_bitbucket_token, 2);
|
||||
if (count($parts) !== 2 || $parts[0] !== 'sha256') {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'Invalid signature.',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
$hash = $parts[1];
|
||||
$payloadHash = hash_hmac('sha256', $payload, $webhook_secret);
|
||||
if (! hash_equals($hash, $payloadHash) && ! isDev()) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
|
|
|
|||
|
|
@ -67,6 +67,15 @@ public function manual(Request $request)
|
|||
}
|
||||
foreach ($applications as $application) {
|
||||
$webhook_secret = data_get($application, 'manual_webhook_secret_gitea');
|
||||
if (empty($webhook_secret)) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'Webhook secret not configured.',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
|
||||
if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) {
|
||||
$return_payloads->push([
|
||||
|
|
|
|||
|
|
@ -81,6 +81,15 @@ public function manual(Request $request)
|
|||
foreach ($applicationsByServer as $serverId => $serverApplications) {
|
||||
foreach ($serverApplications as $application) {
|
||||
$webhook_secret = data_get($application, 'manual_webhook_secret_github');
|
||||
if (empty($webhook_secret)) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'Webhook secret not configured.',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
|
||||
if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) {
|
||||
$return_payloads->push([
|
||||
|
|
|
|||
|
|
@ -100,7 +100,16 @@ public function manual(Request $request)
|
|||
}
|
||||
foreach ($applications as $application) {
|
||||
$webhook_secret = data_get($application, 'manual_webhook_secret_gitlab');
|
||||
if (! hash_equals($webhook_secret ?? '', $x_gitlab_token ?? '')) {
|
||||
if (empty($webhook_secret)) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'Webhook secret not configured.',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! hash_equals($webhook_secret, $x_gitlab_token ?? '')) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ public function back()
|
|||
Auth::login($user);
|
||||
refreshSession($team_to_switch_to);
|
||||
|
||||
return redirect(request()->header('Referer'));
|
||||
return redirect()->route('admin.index');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -70,7 +70,7 @@ public function switchUser(int $user_id)
|
|||
Auth::login($user);
|
||||
refreshSession($team_to_switch_to);
|
||||
|
||||
return redirect(request()->header('Referer'));
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
|
||||
private function authorizeAdminAccess(): void
|
||||
|
|
|
|||
|
|
@ -2,9 +2,7 @@
|
|||
|
||||
namespace App\Livewire\Destination;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\SwarmDocker;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Attributes\Validate;
|
||||
|
|
@ -29,16 +27,8 @@ class Show extends Component
|
|||
public function mount(string $destination_uuid)
|
||||
{
|
||||
try {
|
||||
$destination = StandaloneDocker::whereUuid($destination_uuid)->first() ??
|
||||
SwarmDocker::whereUuid($destination_uuid)->firstOrFail();
|
||||
|
||||
$ownedByTeam = Server::ownedByCurrentTeam()->each(function ($server) use ($destination) {
|
||||
if ($server->standaloneDockers->contains($destination) || $server->swarmDockers->contains($destination)) {
|
||||
$this->destination = $destination;
|
||||
$this->syncData();
|
||||
}
|
||||
});
|
||||
if ($ownedByTeam === false) {
|
||||
$destination = find_destination_for_current_team($destination_uuid);
|
||||
if (! $destination) {
|
||||
return redirect()->route('destination.index');
|
||||
}
|
||||
$this->destination = $destination;
|
||||
|
|
@ -80,7 +70,7 @@ public function delete()
|
|||
try {
|
||||
$this->authorize('delete', $this->destination);
|
||||
|
||||
if ($this->destination->getMorphClass() === \App\Models\StandaloneDocker::class) {
|
||||
if ($this->destination->getMorphClass() === StandaloneDocker::class) {
|
||||
if ($this->destination->attachedTo()) {
|
||||
return $this->dispatch('error', 'You must delete all resources before deleting this destination.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ class Help extends Component
|
|||
#[Validate(['required', 'min:10', 'max:1000'])]
|
||||
public string $description;
|
||||
|
||||
#[Validate(['required', 'min:3'])]
|
||||
#[Validate(['required', 'min:3', 'max:600'])]
|
||||
public string $subject;
|
||||
|
||||
public function submit()
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@
|
|||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Project;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\SwarmDocker;
|
||||
use Livewire\Component;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
|
|
@ -31,7 +29,6 @@ public function mount()
|
|||
|
||||
public function submit()
|
||||
{
|
||||
$server_id = $this->query['server_id'];
|
||||
try {
|
||||
$this->validate([
|
||||
'dockerComposeRaw' => 'required',
|
||||
|
|
@ -44,20 +41,17 @@ public function submit()
|
|||
$project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail();
|
||||
$environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail();
|
||||
|
||||
$destination_uuid = $this->query['destination'];
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
|
||||
$destination_uuid = $this->query['destination'] ?? null;
|
||||
$destination = find_destination_for_current_team($destination_uuid);
|
||||
if (! $destination) {
|
||||
$destination = SwarmDocker::where('uuid', $destination_uuid)->first();
|
||||
}
|
||||
if (! $destination) {
|
||||
throw new \Exception('Destination not found. What?!');
|
||||
throw new \Exception('Destination not found.');
|
||||
}
|
||||
$destination_class = $destination->getMorphClass();
|
||||
|
||||
$service = Service::create([
|
||||
'docker_compose_raw' => $this->dockerComposeRaw,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => (int) $server_id,
|
||||
'server_id' => $destination->server_id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination_class,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@
|
|||
|
||||
use App\Models\Application;
|
||||
use App\Models\Project;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\SwarmDocker;
|
||||
use App\Services\DockerImageParser;
|
||||
use Livewire\Component;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
|
@ -111,13 +109,10 @@ public function submit()
|
|||
$parser = new DockerImageParser;
|
||||
$parser->parse($dockerImage);
|
||||
|
||||
$destination_uuid = $this->query['destination'];
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
|
||||
$destination_uuid = $this->query['destination'] ?? null;
|
||||
$destination = find_destination_for_current_team($destination_uuid);
|
||||
if (! $destination) {
|
||||
$destination = SwarmDocker::where('uuid', $destination_uuid)->first();
|
||||
}
|
||||
if (! $destination) {
|
||||
throw new \Exception('Destination not found. What?!');
|
||||
throw new \Exception('Destination not found.');
|
||||
}
|
||||
$destination_class = $destination->getMorphClass();
|
||||
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@
|
|||
use App\Models\Application;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\Project;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\SwarmDocker;
|
||||
use App\Rules\ValidGitBranch;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
|
@ -178,13 +176,10 @@ public function submit()
|
|||
throw new \RuntimeException('Invalid repository data: '.$validator->errors()->first());
|
||||
}
|
||||
|
||||
$destination_uuid = $this->query['destination'];
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
|
||||
$destination_uuid = $this->query['destination'] ?? null;
|
||||
$destination = find_destination_for_current_team($destination_uuid);
|
||||
if (! $destination) {
|
||||
$destination = SwarmDocker::where('uuid', $destination_uuid)->first();
|
||||
}
|
||||
if (! $destination) {
|
||||
throw new \Exception('Destination not found. What?!');
|
||||
throw new \Exception('Destination not found.');
|
||||
}
|
||||
$destination_class = $destination->getMorphClass();
|
||||
|
||||
|
|
|
|||
|
|
@ -7,8 +7,6 @@
|
|||
use App\Models\GitlabApp;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\SwarmDocker;
|
||||
use App\Rules\ValidGitBranch;
|
||||
use App\Rules\ValidGitRepositoryUrl;
|
||||
use App\Support\ValidationPatterns;
|
||||
|
|
@ -130,13 +128,10 @@ public function submit()
|
|||
{
|
||||
$this->validate();
|
||||
try {
|
||||
$destination_uuid = $this->query['destination'];
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
|
||||
$destination_uuid = $this->query['destination'] ?? null;
|
||||
$destination = find_destination_for_current_team($destination_uuid);
|
||||
if (! $destination) {
|
||||
$destination = SwarmDocker::where('uuid', $destination_uuid)->first();
|
||||
}
|
||||
if (! $destination) {
|
||||
throw new \Exception('Destination not found. What?!');
|
||||
throw new \Exception('Destination not found.');
|
||||
}
|
||||
$destination_class = $destination->getMorphClass();
|
||||
|
||||
|
|
|
|||
|
|
@ -7,8 +7,6 @@
|
|||
use App\Models\GitlabApp;
|
||||
use App\Models\Project;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\SwarmDocker;
|
||||
use App\Rules\ValidGitBranch;
|
||||
use App\Rules\ValidGitRepositoryUrl;
|
||||
use App\Support\ValidationPatterns;
|
||||
|
|
@ -34,8 +32,6 @@ class PublicGitRepository extends Component
|
|||
|
||||
public bool $isStatic = false;
|
||||
|
||||
public bool $checkCoolifyConfig = true;
|
||||
|
||||
public ?string $publish_directory = null;
|
||||
|
||||
// In case of docker compose
|
||||
|
|
@ -284,16 +280,13 @@ public function submit()
|
|||
throw new \RuntimeException('Invalid branch: '.$branchValidator->errors()->first('git_branch'));
|
||||
}
|
||||
|
||||
$destination_uuid = $this->query['destination'];
|
||||
$destination_uuid = $this->query['destination'] ?? null;
|
||||
$project_uuid = $this->parameters['project_uuid'];
|
||||
$environment_uuid = $this->parameters['environment_uuid'];
|
||||
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
|
||||
$destination = find_destination_for_current_team($destination_uuid);
|
||||
if (! $destination) {
|
||||
$destination = SwarmDocker::where('uuid', $destination_uuid)->first();
|
||||
}
|
||||
if (! $destination) {
|
||||
throw new \Exception('Destination not found. What?!');
|
||||
throw new \Exception('Destination not found.');
|
||||
}
|
||||
$destination_class = $destination->getMorphClass();
|
||||
|
||||
|
|
@ -371,12 +364,6 @@ public function submit()
|
|||
$fqdn = generateUrl(server: $destination->server, random: $application->uuid);
|
||||
$application->fqdn = $fqdn;
|
||||
$application->save();
|
||||
if ($this->checkCoolifyConfig) {
|
||||
// $config = loadConfigFromGit($this->repository_url, $this->git_branch, $this->base_directory, $this->query['server_id'], auth()->user()->currentTeam()->id);
|
||||
// if ($config) {
|
||||
// $application->setConfig($config);
|
||||
// }
|
||||
}
|
||||
|
||||
return redirect()->route('project.application.configuration', [
|
||||
'application_uuid' => $application->uuid,
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@
|
|||
use App\Models\Application;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\Project;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\SwarmDocker;
|
||||
use Livewire\Component;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
|
|
@ -35,13 +33,10 @@ public function submit()
|
|||
$this->validate([
|
||||
'dockerfile' => 'required',
|
||||
]);
|
||||
$destination_uuid = $this->query['destination'];
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
|
||||
$destination_uuid = $this->query['destination'] ?? null;
|
||||
$destination = find_destination_for_current_team($destination_uuid);
|
||||
if (! $destination) {
|
||||
$destination = SwarmDocker::where('uuid', $destination_uuid)->first();
|
||||
}
|
||||
if (! $destination) {
|
||||
throw new \Exception('Destination not found. What?!');
|
||||
throw new \Exception('Destination not found.');
|
||||
}
|
||||
$destination_class = $destination->getMorphClass();
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneDocker;
|
||||
use Livewire\Component;
|
||||
|
||||
class Create extends Component
|
||||
|
|
@ -18,7 +17,6 @@ public function mount()
|
|||
|
||||
$type = str(request()->query('type'));
|
||||
$destination_uuid = request()->query('destination');
|
||||
$server_id = request()->query('server_id');
|
||||
$database_image = request()->query('database_image');
|
||||
|
||||
$project = currentTeam()->load(['projects'])->projects->where('uuid', request()->route('project_uuid'))->first();
|
||||
|
|
@ -30,7 +28,11 @@ public function mount()
|
|||
if (! $environment) {
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
if (isset($type) && isset($destination_uuid) && isset($server_id)) {
|
||||
if (isset($type) && isset($destination_uuid)) {
|
||||
$destination = find_destination_for_current_team($destination_uuid);
|
||||
if (! $destination) {
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
$services = get_service_templates();
|
||||
|
||||
if (in_array($type, DATABASE_TYPES)) {
|
||||
|
|
@ -44,23 +46,23 @@ public function mount()
|
|||
}
|
||||
$database = create_standalone_postgresql(
|
||||
environmentId: $environment->id,
|
||||
destinationUuid: $destination_uuid,
|
||||
destination: $destination,
|
||||
databaseImage: $database_image
|
||||
);
|
||||
} elseif ($type->value() === 'redis') {
|
||||
$database = create_standalone_redis($environment->id, $destination_uuid);
|
||||
$database = create_standalone_redis($environment->id, $destination);
|
||||
} elseif ($type->value() === 'mongodb') {
|
||||
$database = create_standalone_mongodb($environment->id, $destination_uuid);
|
||||
$database = create_standalone_mongodb($environment->id, $destination);
|
||||
} elseif ($type->value() === 'mysql') {
|
||||
$database = create_standalone_mysql($environment->id, $destination_uuid);
|
||||
$database = create_standalone_mysql($environment->id, $destination);
|
||||
} elseif ($type->value() === 'mariadb') {
|
||||
$database = create_standalone_mariadb($environment->id, $destination_uuid);
|
||||
$database = create_standalone_mariadb($environment->id, $destination);
|
||||
} elseif ($type->value() === 'keydb') {
|
||||
$database = create_standalone_keydb($environment->id, $destination_uuid);
|
||||
$database = create_standalone_keydb($environment->id, $destination);
|
||||
} elseif ($type->value() === 'dragonfly') {
|
||||
$database = create_standalone_dragonfly($environment->id, $destination_uuid);
|
||||
$database = create_standalone_dragonfly($environment->id, $destination);
|
||||
} elseif ($type->value() === 'clickhouse') {
|
||||
$database = create_standalone_clickhouse($environment->id, $destination_uuid);
|
||||
$database = create_standalone_clickhouse($environment->id, $destination);
|
||||
}
|
||||
|
||||
return redirect()->route('project.database.configuration', [
|
||||
|
|
@ -69,7 +71,7 @@ public function mount()
|
|||
'database_uuid' => $database->uuid,
|
||||
]);
|
||||
}
|
||||
if ($type->startsWith('one-click-service-') && ! is_null((int) $server_id)) {
|
||||
if ($type->startsWith('one-click-service-')) {
|
||||
$oneClickServiceName = $type->after('one-click-service-')->value();
|
||||
$oneClickService = data_get($services, "$oneClickServiceName.compose");
|
||||
$oneClickDotEnvs = data_get($services, "$oneClickServiceName.envs", null);
|
||||
|
|
@ -79,12 +81,11 @@ public function mount()
|
|||
});
|
||||
}
|
||||
if ($oneClickService) {
|
||||
$destination = StandaloneDocker::whereUuid($destination_uuid)->first();
|
||||
$service_payload = [
|
||||
'docker_compose_raw' => base64_decode($oneClickService),
|
||||
'environment_id' => $environment->id,
|
||||
'service_type' => $oneClickServiceName,
|
||||
'server_id' => (int) $server_id,
|
||||
'server_id' => $destination->server_id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -58,10 +58,9 @@ public function cloneTo($destination_id)
|
|||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
$teamScope = fn ($q) => $q->where('team_id', currentTeam()->id);
|
||||
$new_destination = StandaloneDocker::whereHas('server', $teamScope)->find($destination_id);
|
||||
$new_destination = StandaloneDocker::ownedByCurrentTeam()->find($destination_id);
|
||||
if (! $new_destination) {
|
||||
$new_destination = SwarmDocker::whereHas('server', $teamScope)->find($destination_id);
|
||||
$new_destination = SwarmDocker::ownedByCurrentTeam()->find($destination_id);
|
||||
}
|
||||
if (! $new_destination) {
|
||||
return $this->addError('destination_id', 'Destination not found.');
|
||||
|
|
|
|||
|
|
@ -25,7 +25,9 @@ public function mount(): void
|
|||
|
||||
public function disableS3(int $backupId): void
|
||||
{
|
||||
$backup = ScheduledDatabaseBackup::findOrFail($backupId);
|
||||
$backup = ScheduledDatabaseBackup::where('id', $backupId)
|
||||
->where('s3_storage_id', $this->storage->id)
|
||||
->firstOrFail();
|
||||
|
||||
$backup->update([
|
||||
'save_s3' => false,
|
||||
|
|
@ -39,7 +41,9 @@ public function disableS3(int $backupId): void
|
|||
|
||||
public function moveBackup(int $backupId): void
|
||||
{
|
||||
$backup = ScheduledDatabaseBackup::findOrFail($backupId);
|
||||
$backup = ScheduledDatabaseBackup::where('id', $backupId)
|
||||
->where('s3_storage_id', $this->storage->id)
|
||||
->firstOrFail();
|
||||
$newStorageId = $this->selectedStorages[$backupId] ?? null;
|
||||
|
||||
if (! $newStorageId || (int) $newStorageId === $this->storage->id) {
|
||||
|
|
|
|||
|
|
@ -215,14 +215,27 @@ class Application extends BaseModel
|
|||
|
||||
protected $appends = ['server_status'];
|
||||
|
||||
protected $casts = [
|
||||
'http_basic_auth_password' => 'encrypted',
|
||||
'restart_count' => 'integer',
|
||||
'last_restart_at' => 'datetime',
|
||||
];
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'http_basic_auth_password' => 'encrypted',
|
||||
'manual_webhook_secret_github' => 'encrypted',
|
||||
'manual_webhook_secret_gitlab' => 'encrypted',
|
||||
'manual_webhook_secret_bitbucket' => 'encrypted',
|
||||
'manual_webhook_secret_gitea' => 'encrypted',
|
||||
'restart_count' => 'integer',
|
||||
'last_restart_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
static::creating(function ($application) {
|
||||
$application->manual_webhook_secret_github ??= Str::random(40);
|
||||
$application->manual_webhook_secret_gitlab ??= Str::random(40);
|
||||
$application->manual_webhook_secret_bitbucket ??= Str::random(40);
|
||||
$application->manual_webhook_secret_gitea ??= Str::random(40);
|
||||
});
|
||||
static::addGlobalScope('withRelations', function ($builder) {
|
||||
$builder->withCount([
|
||||
'additional_servers',
|
||||
|
|
|
|||
|
|
@ -66,6 +66,13 @@ public static function ownedByCurrentTeam(array $select = ['*'])
|
|||
return S3Storage::whereTeamId(currentTeam()->id)->select($selectArray->all())->orderBy('name');
|
||||
}
|
||||
|
||||
public static function ownedByCurrentTeamAPI(int $teamId, array $select = ['*'])
|
||||
{
|
||||
$selectArray = collect($select)->concat(['id']);
|
||||
|
||||
return S3Storage::whereTeamId($teamId)->select($selectArray->all())->orderBy('name');
|
||||
}
|
||||
|
||||
public function isUsable()
|
||||
{
|
||||
return $this->is_usable;
|
||||
|
|
|
|||
|
|
@ -90,6 +90,16 @@ public function server()
|
|||
return $this->belongsTo(Server::class);
|
||||
}
|
||||
|
||||
public static function ownedByCurrentTeam()
|
||||
{
|
||||
return static::whereHas('server', fn ($q) => $q->whereTeamId(currentTeam()->id));
|
||||
}
|
||||
|
||||
public static function ownedByCurrentTeamAPI(int $teamId)
|
||||
{
|
||||
return static::whereHas('server', fn ($q) => $q->whereTeamId($teamId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the server attribute using identity map caching.
|
||||
* This intercepts lazy-loading to use cached Server lookups.
|
||||
|
|
|
|||
|
|
@ -71,6 +71,16 @@ public function server()
|
|||
return $this->belongsTo(Server::class);
|
||||
}
|
||||
|
||||
public static function ownedByCurrentTeam()
|
||||
{
|
||||
return static::whereHas('server', fn ($q) => $q->whereTeamId(currentTeam()->id));
|
||||
}
|
||||
|
||||
public static function ownedByCurrentTeamAPI(int $teamId)
|
||||
{
|
||||
return static::whereHas('server', fn ($q) => $q->whereTeamId($teamId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the server attribute using identity map caching.
|
||||
* This intercepts lazy-loading to use cached Server lookups.
|
||||
|
|
|
|||
|
|
@ -54,5 +54,9 @@ protected function configureRateLimiting(): void
|
|||
RateLimiter::for('5', function (Request $request) {
|
||||
return Limit::perMinute(5)->by($request->user()?->id ?: $request->ip());
|
||||
});
|
||||
|
||||
RateLimiter::for('feedback', function (Request $request) {
|
||||
return Limit::perMinute(3)->by($request->user()?->id ?: $request->ip());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
|
|
@ -12,18 +13,19 @@
|
|||
use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Models\SwarmDocker;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
function create_standalone_postgresql($environmentId, $destinationUuid, ?array $otherData = null, string $databaseImage = 'postgres:16-alpine'): StandalonePostgresql
|
||||
function create_standalone_postgresql($environmentId, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null, string $databaseImage = 'postgres:16-alpine'): StandalonePostgresql
|
||||
{
|
||||
$destination = StandaloneDocker::where('uuid', $destinationUuid)->firstOrFail();
|
||||
$database = new StandalonePostgresql;
|
||||
$database->uuid = (new Cuid2);
|
||||
$database->name = 'postgresql-database-'.$database->uuid;
|
||||
$database->image = $databaseImage;
|
||||
$database->postgres_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
|
||||
$database->postgres_password = Str::password(length: 64, symbols: false);
|
||||
$database->environment_id = $environmentId;
|
||||
$database->destination_id = $destination->id;
|
||||
$database->destination_type = $destination->getMorphClass();
|
||||
|
|
@ -35,14 +37,13 @@ function create_standalone_postgresql($environmentId, $destinationUuid, ?array $
|
|||
return $database;
|
||||
}
|
||||
|
||||
function create_standalone_redis($environment_id, $destination_uuid, ?array $otherData = null): StandaloneRedis
|
||||
function create_standalone_redis($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneRedis
|
||||
{
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
|
||||
$database = new StandaloneRedis;
|
||||
$database->uuid = (new Cuid2);
|
||||
$database->name = 'redis-database-'.$database->uuid;
|
||||
|
||||
$redis_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
|
||||
$redis_password = Str::password(length: 64, symbols: false);
|
||||
if ($otherData && isset($otherData['redis_password'])) {
|
||||
$redis_password = $otherData['redis_password'];
|
||||
unset($otherData['redis_password']);
|
||||
|
|
@ -75,13 +76,12 @@ function create_standalone_redis($environment_id, $destination_uuid, ?array $oth
|
|||
return $database;
|
||||
}
|
||||
|
||||
function create_standalone_mongodb($environment_id, $destination_uuid, ?array $otherData = null): StandaloneMongodb
|
||||
function create_standalone_mongodb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMongodb
|
||||
{
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
|
||||
$database = new StandaloneMongodb;
|
||||
$database->uuid = (new Cuid2);
|
||||
$database->name = 'mongodb-database-'.$database->uuid;
|
||||
$database->mongo_initdb_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
|
||||
$database->mongo_initdb_root_password = Str::password(length: 64, symbols: false);
|
||||
$database->environment_id = $environment_id;
|
||||
$database->destination_id = $destination->id;
|
||||
$database->destination_type = $destination->getMorphClass();
|
||||
|
|
@ -93,14 +93,13 @@ function create_standalone_mongodb($environment_id, $destination_uuid, ?array $o
|
|||
return $database;
|
||||
}
|
||||
|
||||
function create_standalone_mysql($environment_id, $destination_uuid, ?array $otherData = null): StandaloneMysql
|
||||
function create_standalone_mysql($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMysql
|
||||
{
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
|
||||
$database = new StandaloneMysql;
|
||||
$database->uuid = (new Cuid2);
|
||||
$database->name = 'mysql-database-'.$database->uuid;
|
||||
$database->mysql_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
|
||||
$database->mysql_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
|
||||
$database->mysql_root_password = Str::password(length: 64, symbols: false);
|
||||
$database->mysql_password = Str::password(length: 64, symbols: false);
|
||||
$database->environment_id = $environment_id;
|
||||
$database->destination_id = $destination->id;
|
||||
$database->destination_type = $destination->getMorphClass();
|
||||
|
|
@ -112,14 +111,13 @@ function create_standalone_mysql($environment_id, $destination_uuid, ?array $oth
|
|||
return $database;
|
||||
}
|
||||
|
||||
function create_standalone_mariadb($environment_id, $destination_uuid, ?array $otherData = null): StandaloneMariadb
|
||||
function create_standalone_mariadb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMariadb
|
||||
{
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
|
||||
$database = new StandaloneMariadb;
|
||||
$database->uuid = (new Cuid2);
|
||||
$database->name = 'mariadb-database-'.$database->uuid;
|
||||
$database->mariadb_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
|
||||
$database->mariadb_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
|
||||
$database->mariadb_root_password = Str::password(length: 64, symbols: false);
|
||||
$database->mariadb_password = Str::password(length: 64, symbols: false);
|
||||
$database->environment_id = $environment_id;
|
||||
$database->destination_id = $destination->id;
|
||||
$database->destination_type = $destination->getMorphClass();
|
||||
|
|
@ -131,13 +129,12 @@ function create_standalone_mariadb($environment_id, $destination_uuid, ?array $o
|
|||
return $database;
|
||||
}
|
||||
|
||||
function create_standalone_keydb($environment_id, $destination_uuid, ?array $otherData = null): StandaloneKeydb
|
||||
function create_standalone_keydb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneKeydb
|
||||
{
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
|
||||
$database = new StandaloneKeydb;
|
||||
$database->uuid = (new Cuid2);
|
||||
$database->name = 'keydb-database-'.$database->uuid;
|
||||
$database->keydb_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
|
||||
$database->keydb_password = Str::password(length: 64, symbols: false);
|
||||
$database->environment_id = $environment_id;
|
||||
$database->destination_id = $destination->id;
|
||||
$database->destination_type = $destination->getMorphClass();
|
||||
|
|
@ -149,13 +146,12 @@ function create_standalone_keydb($environment_id, $destination_uuid, ?array $oth
|
|||
return $database;
|
||||
}
|
||||
|
||||
function create_standalone_dragonfly($environment_id, $destination_uuid, ?array $otherData = null): StandaloneDragonfly
|
||||
function create_standalone_dragonfly($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneDragonfly
|
||||
{
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
|
||||
$database = new StandaloneDragonfly;
|
||||
$database->uuid = (new Cuid2);
|
||||
$database->name = 'dragonfly-database-'.$database->uuid;
|
||||
$database->dragonfly_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
|
||||
$database->dragonfly_password = Str::password(length: 64, symbols: false);
|
||||
$database->environment_id = $environment_id;
|
||||
$database->destination_id = $destination->id;
|
||||
$database->destination_type = $destination->getMorphClass();
|
||||
|
|
@ -167,13 +163,12 @@ function create_standalone_dragonfly($environment_id, $destination_uuid, ?array
|
|||
return $database;
|
||||
}
|
||||
|
||||
function create_standalone_clickhouse($environment_id, $destination_uuid, ?array $otherData = null): StandaloneClickhouse
|
||||
function create_standalone_clickhouse($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneClickhouse
|
||||
{
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
|
||||
$database = new StandaloneClickhouse;
|
||||
$database->uuid = (new Cuid2);
|
||||
$database->name = 'clickhouse-database-'.$database->uuid;
|
||||
$database->clickhouse_admin_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
|
||||
$database->clickhouse_admin_password = Str::password(length: 64, symbols: false);
|
||||
$database->environment_id = $environment_id;
|
||||
$database->destination_id = $destination->id;
|
||||
$database->destination_type = $destination->getMorphClass();
|
||||
|
|
@ -279,7 +274,7 @@ function removeOldBackups($backup): void
|
|||
->whereNull('s3_uploaded')
|
||||
->delete();
|
||||
|
||||
} catch (\Exception $e) {
|
||||
} catch (Exception $e) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
|
@ -345,7 +340,7 @@ function deleteOldBackupsLocally($backup): Collection
|
|||
$processedBackups = collect();
|
||||
|
||||
$server = null;
|
||||
if ($backup->database_type === \App\Models\ServiceDatabase::class) {
|
||||
if ($backup->database_type === ServiceDatabase::class) {
|
||||
$server = $backup->database->service->server;
|
||||
} else {
|
||||
$server = $backup->database->destination->server;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
use App\Models\ServiceDatabase;
|
||||
use App\Models\SharedEnvironmentVariable;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
use App\Models\StandaloneMariadb;
|
||||
|
|
@ -25,6 +26,7 @@
|
|||
use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Models\SwarmDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
|
@ -259,6 +261,16 @@ function currentTeam()
|
|||
return Auth::user()?->currentTeam() ?? null;
|
||||
}
|
||||
|
||||
function find_destination_for_current_team(?string $uuid): StandaloneDocker|SwarmDocker|null
|
||||
{
|
||||
if (blank($uuid) || ! currentTeam()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return StandaloneDocker::ownedByCurrentTeam()->where('uuid', $uuid)->first()
|
||||
?? SwarmDocker::ownedByCurrentTeam()->where('uuid', $uuid)->first();
|
||||
}
|
||||
|
||||
function showBoarding(): bool
|
||||
{
|
||||
if (isDev()) {
|
||||
|
|
@ -3489,34 +3501,6 @@ function getHelperVersion(): string
|
|||
return config('constants.coolify.helper_version');
|
||||
}
|
||||
|
||||
function loadConfigFromGit(string $repository, string $branch, string $base_directory, int $server_id, int $team_id)
|
||||
{
|
||||
$server = Server::find($server_id)->where('team_id', $team_id)->first();
|
||||
if (! $server) {
|
||||
return;
|
||||
}
|
||||
$uuid = new Cuid2;
|
||||
$cloneCommand = "git clone --no-checkout -b $branch $repository .";
|
||||
$workdir = rtrim($base_directory, '/');
|
||||
$fileList = collect([".$workdir/coolify.json"]);
|
||||
$commands = collect([
|
||||
"rm -rf /tmp/{$uuid}",
|
||||
"mkdir -p /tmp/{$uuid}",
|
||||
"cd /tmp/{$uuid}",
|
||||
$cloneCommand,
|
||||
'git sparse-checkout init --cone',
|
||||
"git sparse-checkout set {$fileList->implode(' ')}",
|
||||
'git read-tree -mu HEAD',
|
||||
"cat .$workdir/coolify.json",
|
||||
'rm -rf /tmp/{$uuid}',
|
||||
]);
|
||||
try {
|
||||
return instant_remote_process($commands, $server);
|
||||
} catch (Exception) {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
|
||||
function loggy($message = null, array $context = [])
|
||||
{
|
||||
if (! isDev()) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class BackfillAndEncryptWebhookSecrets extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$columns = [
|
||||
'manual_webhook_secret_github',
|
||||
'manual_webhook_secret_gitlab',
|
||||
'manual_webhook_secret_bitbucket',
|
||||
'manual_webhook_secret_gitea',
|
||||
];
|
||||
|
||||
Schema::table('applications', function ($table) use ($columns) {
|
||||
foreach ($columns as $col) {
|
||||
$table->text($col)->nullable()->change();
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
DB::table('applications')->chunkById(100, function ($apps) use ($columns) {
|
||||
foreach ($apps as $app) {
|
||||
$updates = [];
|
||||
foreach ($columns as $col) {
|
||||
$current = $app->{$col};
|
||||
|
||||
if (empty($current)) {
|
||||
$updates[$col] = Crypt::encryptString(Str::random(40));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
Crypt::decryptString($current);
|
||||
|
||||
continue;
|
||||
} catch (Exception) {
|
||||
// Not encrypted yet
|
||||
}
|
||||
|
||||
$updates[$col] = Crypt::encryptString($current);
|
||||
}
|
||||
if ($updates !== []) {
|
||||
DB::table('applications')->where('id', $app->id)->update($updates);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (Exception $e) {
|
||||
echo 'Backfilling and encrypting webhook secrets failed.';
|
||||
echo $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
64
openapi.json
64
openapi.json
|
|
@ -3788,6 +3788,70 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"\/applications\/{uuid}\/previews\/{pull_request_id}": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Applications"
|
||||
],
|
||||
"summary": "Delete Preview Deployment",
|
||||
"description": "Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes\/networks, and deletes the preview record.",
|
||||
"operationId": "delete-preview-deployment-by-pull-request-id",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "uuid",
|
||||
"in": "path",
|
||||
"description": "UUID of the application.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "pull_request_id",
|
||||
"in": "path",
|
||||
"description": "Pull request ID of the preview to delete.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Preview deletion queued.",
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#\/components\/responses\/400"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
},
|
||||
"422": {
|
||||
"$ref": "#\/components\/responses\/422"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/cloud-tokens": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
|
|
|||
42
openapi.yaml
42
openapi.yaml
|
|
@ -2398,6 +2398,48 @@ paths:
|
|||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
'/applications/{uuid}/previews/{pull_request_id}':
|
||||
delete:
|
||||
tags:
|
||||
- Applications
|
||||
summary: 'Delete Preview Deployment'
|
||||
description: 'Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes/networks, and deletes the preview record.'
|
||||
operationId: delete-preview-deployment-by-pull-request-id
|
||||
parameters:
|
||||
-
|
||||
name: uuid
|
||||
in: path
|
||||
description: 'UUID of the application.'
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
-
|
||||
name: pull_request_id
|
||||
in: path
|
||||
description: 'Pull request ID of the preview to delete.'
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: 'Preview deletion queued.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
message: { type: string }
|
||||
type: object
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'400':
|
||||
$ref: '#/components/responses/400'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'422':
|
||||
$ref: '#/components/responses/422'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
/cloud-tokens:
|
||||
get:
|
||||
tags:
|
||||
|
|
|
|||
6
package-lock.json
generated
6
package-lock.json
generated
|
|
@ -1781,9 +1781,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -26,7 +26,8 @@
|
|||
Route::get('/health', [OtherController::class, 'healthcheck']);
|
||||
});
|
||||
|
||||
Route::post('/feedback', [OtherController::class, 'feedback']);
|
||||
Route::post('/feedback', [OtherController::class, 'feedback'])
|
||||
->middleware('throttle:feedback');
|
||||
|
||||
Route::group([
|
||||
'middleware' => ['auth:sanctum', 'api.ability:write'],
|
||||
|
|
@ -129,6 +130,8 @@
|
|||
Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']);
|
||||
Route::match(['get', 'post'], '/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:deploy']);
|
||||
|
||||
Route::delete('/applications/{uuid}/previews/{pull_request_id}', [ApplicationsController::class, 'delete_preview_by_pull_request_id'])->middleware(['api.ability:write']);
|
||||
|
||||
Route::get('/github-apps', [GithubController::class, 'list_github_apps'])->middleware(['api.ability:read']);
|
||||
Route::post('/github-apps', [GithubController::class, 'create_github_app'])->middleware(['api.ability:write']);
|
||||
Route::patch('/github-apps/{github_app_id}', [GithubController::class, 'update_github_app'])->middleware(['api.ability:write']);
|
||||
|
|
@ -218,7 +221,7 @@
|
|||
try {
|
||||
$decrypted = decrypt($naked_token);
|
||||
$decrypted_token = json_decode($decrypted, true);
|
||||
} catch (\Exception $e) {
|
||||
} catch (Exception $e) {
|
||||
return response()->json(['message' => 'Invalid token'], 401);
|
||||
}
|
||||
$server_uuid = data_get($decrypted_token, 'server_uuid');
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Admin\Index as AdminIndex;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
|
@ -70,9 +71,9 @@
|
|||
test('switchUser requires root user id 0', function () {
|
||||
config()->set('constants.coolify.self_hosted', false);
|
||||
|
||||
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
|
||||
$rootUser = User::factory()->create(['id' => 0]);
|
||||
$rootTeam->members()->attach($rootUser->id, ['role' => 'admin']);
|
||||
$rootTeam = Team::find(0);
|
||||
|
||||
$targetUser = User::factory()->create();
|
||||
$targetTeam = Team::factory()->create();
|
||||
|
|
@ -84,7 +85,47 @@
|
|||
Livewire::test(AdminIndex::class)
|
||||
->assertOk()
|
||||
->call('switchUser', $targetUser->id)
|
||||
->assertRedirect();
|
||||
->assertRedirect(route('dashboard'));
|
||||
});
|
||||
|
||||
test('back() redirects impersonator to admin index and clears session', function () {
|
||||
config()->set('constants.coolify.self_hosted', false);
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
|
||||
$rootUser = User::factory()->create(['id' => 0]);
|
||||
$rootTeam = Team::find(0);
|
||||
|
||||
$this->actingAs($rootUser);
|
||||
session([
|
||||
'currentTeam' => ['id' => $rootTeam->id],
|
||||
'impersonating' => true,
|
||||
]);
|
||||
|
||||
Livewire::test(AdminIndex::class)
|
||||
->call('back')
|
||||
->assertRedirect(route('admin.index'));
|
||||
|
||||
expect(session('impersonating'))->toBeNull();
|
||||
});
|
||||
|
||||
test('switchUser ignores Referer header and uses dashboard route', function () {
|
||||
config()->set('constants.coolify.self_hosted', false);
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
|
||||
$rootUser = User::factory()->create(['id' => 0]);
|
||||
$rootTeam = Team::find(0);
|
||||
|
||||
$targetUser = User::factory()->create();
|
||||
$targetTeam = Team::factory()->create();
|
||||
$targetTeam->members()->attach($targetUser->id, ['role' => 'admin']);
|
||||
|
||||
$this->actingAs($rootUser);
|
||||
session(['currentTeam' => ['id' => $rootTeam->id]]);
|
||||
|
||||
Livewire::withHeaders(['Referer' => 'https://example.com/elsewhere'])
|
||||
->test(AdminIndex::class)
|
||||
->call('switchUser', $targetUser->id)
|
||||
->assertRedirect(route('dashboard'));
|
||||
});
|
||||
|
||||
test('switchUser rejects non-root user', function () {
|
||||
|
|
|
|||
132
tests/Feature/ApplicationPreviewApiTest.php
Normal file
132
tests/Feature/ApplicationPreviewApiTest.php
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
<?php
|
||||
|
||||
use App\Actions\Application\CleanupPreviewDeployment;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Str;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Bus::fake();
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->bearerToken = createTeamApiToken($this->user, $this->team, ['*']);
|
||||
|
||||
$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 = 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(),
|
||||
]);
|
||||
|
||||
CleanupPreviewDeployment::shouldRun()->andReturn([
|
||||
'cancelled_deployments' => 0,
|
||||
'killed_containers' => 0,
|
||||
'status' => 'success',
|
||||
]);
|
||||
});
|
||||
|
||||
function previewAuthHeaders(string $bearerToken): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer '.$bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
];
|
||||
}
|
||||
|
||||
function createTeamApiToken(User $user, Team $team, array $abilities): string
|
||||
{
|
||||
$plainTextToken = Str::random(40);
|
||||
$token = $user->tokens()->create([
|
||||
'name' => 'test-token-'.Str::random(6),
|
||||
'token' => hash('sha256', $plainTextToken),
|
||||
'abilities' => $abilities,
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
return $token->getKey().'|'.$plainTextToken;
|
||||
}
|
||||
|
||||
function createPreview(Application $application, int $pullRequestId): ApplicationPreview
|
||||
{
|
||||
return ApplicationPreview::create([
|
||||
'uuid' => (string) new Cuid2,
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => $pullRequestId,
|
||||
'pull_request_html_url' => "https://github.com/example/repo/pull/{$pullRequestId}",
|
||||
'fqdn' => "pr-{$pullRequestId}.example.com",
|
||||
]);
|
||||
}
|
||||
|
||||
describe('DELETE /api/v1/applications/{uuid}/previews/{pull_request_id}', function () {
|
||||
test('returns 401 when no bearer token provided', function () {
|
||||
$response = $this->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/42");
|
||||
|
||||
$response->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('returns 404 when application uuid does not exist', function () {
|
||||
$response = $this->withHeaders(previewAuthHeaders($this->bearerToken))
|
||||
->deleteJson('/api/v1/applications/nonexistent-uuid/previews/42');
|
||||
|
||||
$response->assertNotFound()
|
||||
->assertJson(['message' => 'Application not found.']);
|
||||
});
|
||||
|
||||
test('returns 404 when preview does not exist for the application', function () {
|
||||
$response = $this->withHeaders(previewAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/9999");
|
||||
|
||||
$response->assertNotFound()
|
||||
->assertJson(['message' => 'Preview not found.']);
|
||||
});
|
||||
|
||||
test('returns 422 when pull_request_id is not a positive integer', function () {
|
||||
$response = $this->withHeaders(previewAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/0");
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJson(['message' => 'Invalid pull_request_id.']);
|
||||
});
|
||||
|
||||
test('soft-deletes the preview and returns 200 on success', function () {
|
||||
$preview = createPreview($this->application, 42);
|
||||
|
||||
$response = $this->withHeaders(previewAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/42");
|
||||
|
||||
$response->assertOk()
|
||||
->assertJson(['message' => 'Preview deletion request queued.']);
|
||||
|
||||
expect($preview->fresh()->trashed())->toBeTrue();
|
||||
});
|
||||
|
||||
test('returns 403 when token lacks write ability', function () {
|
||||
$readOnlyToken = createTeamApiToken($this->user, $this->team, ['read']);
|
||||
createPreview($this->application, 7);
|
||||
|
||||
$response = $this->withHeaders(previewAuthHeaders($readOnlyToken))
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/7");
|
||||
|
||||
$response->assertForbidden();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,12 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
|
|
@ -8,50 +15,110 @@
|
|||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
// Create a team with owner
|
||||
InstanceSettings::updateOrCreate(['id' => 0]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
// Create an API token for the user
|
||||
$this->token = $this->user->createToken('test-token', ['*'], $this->team->id);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->token = $this->user->createToken('test-token', ['*']);
|
||||
$this->bearerToken = $this->token->plainTextToken;
|
||||
|
||||
// Mock a database - we'll use Mockery to avoid needing actual database setup
|
||||
$this->database = \Mockery::mock(StandalonePostgresql::class);
|
||||
$this->database->shouldReceive('getAttribute')->with('id')->andReturn(1);
|
||||
$this->database->shouldReceive('getAttribute')->with('uuid')->andReturn('test-db-uuid');
|
||||
$this->database->shouldReceive('getAttribute')->with('postgres_db')->andReturn('testdb');
|
||||
$this->database->shouldReceive('type')->andReturn('standalone-postgresql');
|
||||
$this->database->shouldReceive('getMorphClass')->andReturn('App\Models\StandalonePostgresql');
|
||||
});
|
||||
$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 = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
|
||||
afterEach(function () {
|
||||
\Mockery::close();
|
||||
$this->database = StandalonePostgresql::create([
|
||||
'name' => 'test-postgres',
|
||||
'image' => 'postgres:15-alpine',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'testdb',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$this->s3Storage = S3Storage::create([
|
||||
'name' => 'test-s3',
|
||||
'region' => 'us-east-1',
|
||||
'key' => 'test-key',
|
||||
'secret' => 'test-secret',
|
||||
'bucket' => 'test-bucket',
|
||||
'endpoint' => 'https://s3.example.com',
|
||||
'team_id' => $this->team->id,
|
||||
'is_usable' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
describe('POST /api/v1/databases/{uuid}/backups', function () {
|
||||
test('creates backup configuration with minimal required fields', function () {
|
||||
// This is a unit-style test using mocks to avoid database dependency
|
||||
// For full integration testing, this should be run inside Docker
|
||||
test('creates backup with s3 storage via API token', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
|
||||
'frequency' => '0 2 * * 0',
|
||||
'save_s3' => true,
|
||||
's3_storage_uuid' => $this->s3Storage->uuid,
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonStructure(['uuid', 'message']);
|
||||
|
||||
$backup = ScheduledDatabaseBackup::where('uuid', $response->json('uuid'))->first();
|
||||
expect($backup)->not->toBeNull();
|
||||
expect($backup->s3_storage_id)->toBe($this->s3Storage->id);
|
||||
expect($backup->save_s3)->toBeTrue();
|
||||
expect($backup->team_id)->toBe($this->team->id);
|
||||
});
|
||||
|
||||
test('creates backup without s3 storage', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
|
||||
'frequency' => 'daily',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonStructure(['uuid', 'message']);
|
||||
});
|
||||
|
||||
test('rejects s3_storage_uuid from another team', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherS3 = S3Storage::create([
|
||||
'name' => 'other-s3',
|
||||
'region' => 'us-east-1',
|
||||
'key' => 'other-key',
|
||||
'secret' => 'other-secret',
|
||||
'bucket' => 'other-bucket',
|
||||
'endpoint' => 'https://s3.example.com',
|
||||
'team_id' => $otherTeam->id,
|
||||
'is_usable' => true,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/databases/test-db-uuid/backups', [
|
||||
'frequency' => 'daily',
|
||||
])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
|
||||
'frequency' => '0 2 * * 0',
|
||||
'save_s3' => true,
|
||||
's3_storage_uuid' => $otherS3->uuid,
|
||||
]);
|
||||
|
||||
// Since we're mocking, this test verifies the endpoint exists and basic validation
|
||||
// Full integration tests should be run in Docker environment
|
||||
expect($response->status())->toBeIn([201, 404, 422]);
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['s3_storage_uuid']);
|
||||
});
|
||||
|
||||
test('validates frequency is required', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/databases/test-db-uuid/backups', [
|
||||
])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
|
|
@ -63,83 +130,78 @@
|
|||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/databases/test-db-uuid/backups', [
|
||||
])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
|
||||
'frequency' => 'daily',
|
||||
'save_s3' => true,
|
||||
]);
|
||||
|
||||
// Should fail validation because s3_storage_uuid is missing
|
||||
expect($response->status())->toBeIn([404, 422]);
|
||||
});
|
||||
|
||||
test('rejects invalid frequency format', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/databases/test-db-uuid/backups', [
|
||||
'frequency' => 'invalid-frequency',
|
||||
]);
|
||||
|
||||
expect($response->status())->toBeIn([404, 422]);
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['s3_storage_uuid']);
|
||||
});
|
||||
|
||||
test('rejects request without authentication', function () {
|
||||
$response = $this->postJson('/api/v1/databases/test-db-uuid/backups', [
|
||||
$response = $this->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
|
||||
'frequency' => 'daily',
|
||||
]);
|
||||
|
||||
$response->assertStatus(401);
|
||||
});
|
||||
});
|
||||
|
||||
test('validates retention fields are integers with minimum 0', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/databases/test-db-uuid/backups', [
|
||||
describe('PATCH /api/v1/databases/{uuid}/backups/{scheduled_backup_uuid}', function () {
|
||||
test('updates backup to use s3 storage via API token', function () {
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => 'daily',
|
||||
'database_backup_retention_amount_locally' => -1,
|
||||
'enabled' => true,
|
||||
'database_id' => $this->database->id,
|
||||
'database_type' => $this->database->getMorphClass(),
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
expect($response->status())->toBeIn([404, 422]);
|
||||
});
|
||||
|
||||
test('accepts valid cron expressions', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/databases/test-db-uuid/backups', [
|
||||
'frequency' => '0 2 * * *', // Daily at 2 AM
|
||||
])->patchJson("/api/v1/databases/{$this->database->uuid}/backups/{$backup->uuid}", [
|
||||
'save_s3' => true,
|
||||
's3_storage_uuid' => $this->s3Storage->uuid,
|
||||
]);
|
||||
|
||||
// Will fail with 404 because database doesn't exist, but validates the request format
|
||||
expect($response->status())->toBeIn([201, 404, 422]);
|
||||
$response->assertStatus(200);
|
||||
$backup->refresh();
|
||||
expect($backup->s3_storage_id)->toBe($this->s3Storage->id);
|
||||
expect($backup->save_s3)->toBeTrue();
|
||||
});
|
||||
|
||||
test('accepts predefined frequency values', function () {
|
||||
$frequencies = ['every_minute', 'hourly', 'daily', 'weekly', 'monthly', 'yearly'];
|
||||
test('rejects s3_storage_uuid from another team on update', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherS3 = S3Storage::create([
|
||||
'name' => 'other-s3',
|
||||
'region' => 'us-east-1',
|
||||
'key' => 'other-key',
|
||||
'secret' => 'other-secret',
|
||||
'bucket' => 'other-bucket',
|
||||
'endpoint' => 'https://s3.example.com',
|
||||
'team_id' => $otherTeam->id,
|
||||
'is_usable' => true,
|
||||
]);
|
||||
|
||||
foreach ($frequencies as $frequency) {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/databases/test-db-uuid/backups', [
|
||||
'frequency' => $frequency,
|
||||
]);
|
||||
|
||||
// Will fail with 404 because database doesn't exist, but validates the request format
|
||||
expect($response->status())->toBeIn([201, 404, 422]);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects extra fields not in allowed list', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/databases/test-db-uuid/backups', [
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => 'daily',
|
||||
'invalid_field' => 'invalid_value',
|
||||
'enabled' => true,
|
||||
'database_id' => $this->database->id,
|
||||
'database_type' => $this->database->getMorphClass(),
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
expect($response->status())->toBeIn([404, 422]);
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson("/api/v1/databases/{$this->database->uuid}/backups/{$backup->uuid}", [
|
||||
'save_s3' => true,
|
||||
's3_storage_uuid' => $otherS3->uuid,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['s3_storage_uuid']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
96
tests/Feature/FeedbackEndpointTest.php
Normal file
96
tests/Feature/FeedbackEndpointTest.php
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function () {
|
||||
Http::fake([
|
||||
'discord.com/*' => Http::response([], 204),
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects feedback with missing content', function () {
|
||||
$response = $this->postJson('/api/feedback', []);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors('content');
|
||||
});
|
||||
|
||||
it('rejects feedback with content too short', function () {
|
||||
$response = $this->postJson('/api/feedback', ['content' => 'short']);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors('content');
|
||||
});
|
||||
|
||||
it('rejects feedback with content too long', function () {
|
||||
$response = $this->postJson('/api/feedback', ['content' => str_repeat('a', 2001)]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors('content');
|
||||
});
|
||||
|
||||
it('rejects feedback with non-string content', function () {
|
||||
$response = $this->postJson('/api/feedback', ['content' => ['array', 'value']]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors('content');
|
||||
});
|
||||
|
||||
it('accepts valid feedback and forwards to discord with mentions disabled', function () {
|
||||
config()->set('constants.webhooks.feedback_discord_webhook', 'https://discord.com/api/webhooks/test');
|
||||
|
||||
$response = $this->postJson('/api/feedback', [
|
||||
'content' => 'This is a valid feedback message for testing purposes.',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson(['message' => 'Feedback sent.']);
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return $request->url() === 'https://discord.com/api/webhooks/test'
|
||||
&& $request['content'] === 'This is a valid feedback message for testing purposes.'
|
||||
&& $request['allowed_mentions'] === ['parse' => []];
|
||||
});
|
||||
});
|
||||
|
||||
it('does not forward to discord when webhook url is not configured', function () {
|
||||
config()->set('constants.webhooks.feedback_discord_webhook', null);
|
||||
|
||||
$response = $this->postJson('/api/feedback', [
|
||||
'content' => 'This is a valid feedback message for testing purposes.',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('throttles feedback endpoint after 3 requests per minute', function () {
|
||||
config()->set('constants.webhooks.feedback_discord_webhook', null);
|
||||
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
$response = $this->postJson('/api/feedback', [
|
||||
'content' => "Valid feedback message number {$i} for testing.",
|
||||
]);
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
$response = $this->postJson('/api/feedback', [
|
||||
'content' => 'This fourth request should be throttled.',
|
||||
]);
|
||||
$response->assertStatus(429);
|
||||
});
|
||||
|
||||
it('disables discord mention parsing regardless of content', function () {
|
||||
config()->set('constants.webhooks.feedback_discord_webhook', 'https://discord.com/api/webhooks/test');
|
||||
|
||||
$response = $this->postJson('/api/feedback', [
|
||||
'content' => 'User feedback includes an @everyone style phrase and a link https://example.com for reference.',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return $request['allowed_mentions'] === ['parse' => []];
|
||||
});
|
||||
});
|
||||
106
tests/Feature/TeamScopedBackupStorageTest.php
Normal file
106
tests/Feature/TeamScopedBackupStorageTest.php
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Storage\Resources as StorageResources;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Queue::fake();
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
|
||||
|
||||
$this->userA = User::factory()->create();
|
||||
$this->teamA = Team::factory()->create();
|
||||
$this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
|
||||
|
||||
$this->userB = User::factory()->create();
|
||||
$this->teamB = Team::factory()->create();
|
||||
$this->userB->teams()->attach($this->teamB, ['role' => 'owner']);
|
||||
|
||||
$this->storageA = S3Storage::unguarded(fn () => S3Storage::create([
|
||||
'uuid' => fake()->uuid(),
|
||||
'name' => 'storage-a-'.fake()->unique()->word(),
|
||||
'region' => 'us-east-1',
|
||||
'key' => 'key-a',
|
||||
'secret' => 'secret-a',
|
||||
'bucket' => 'bucket-a',
|
||||
'endpoint' => 'https://s3.example.com',
|
||||
'team_id' => $this->teamA->id,
|
||||
]));
|
||||
|
||||
$this->storageB = S3Storage::unguarded(fn () => S3Storage::create([
|
||||
'uuid' => fake()->uuid(),
|
||||
'name' => 'storage-b-'.fake()->unique()->word(),
|
||||
'region' => 'us-east-1',
|
||||
'key' => 'key-b',
|
||||
'secret' => 'secret-b',
|
||||
'bucket' => 'bucket-b',
|
||||
'endpoint' => 'https://s3.example.com',
|
||||
'team_id' => $this->teamB->id,
|
||||
]));
|
||||
|
||||
$this->backupA = ScheduledDatabaseBackup::create([
|
||||
'uuid' => fake()->uuid(),
|
||||
'team_id' => $this->teamA->id,
|
||||
'enabled' => true,
|
||||
'save_s3' => true,
|
||||
'frequency' => '0 0 * * *',
|
||||
'database_type' => 'App\\Models\\StandalonePostgresql',
|
||||
'database_id' => 1,
|
||||
's3_storage_id' => $this->storageA->id,
|
||||
]);
|
||||
|
||||
$this->backupB = ScheduledDatabaseBackup::create([
|
||||
'uuid' => fake()->uuid(),
|
||||
'team_id' => $this->teamB->id,
|
||||
'enabled' => true,
|
||||
'save_s3' => true,
|
||||
'frequency' => '0 0 * * *',
|
||||
'database_type' => 'App\\Models\\StandalonePostgresql',
|
||||
'database_id' => 2,
|
||||
's3_storage_id' => $this->storageB->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($this->userA);
|
||||
session(['currentTeam' => $this->teamA]);
|
||||
});
|
||||
|
||||
describe('Storage/Resources team-scoped backup access', function () {
|
||||
test('disableS3 on other team backup throws and leaves row unchanged', function () {
|
||||
expect(fn () => Livewire::test(StorageResources::class, ['storage' => $this->storageA])
|
||||
->call('disableS3', $this->backupB->id))
|
||||
->toThrow(ModelNotFoundException::class);
|
||||
|
||||
$this->backupB->refresh();
|
||||
expect((bool) $this->backupB->save_s3)->toBeTrue();
|
||||
expect($this->backupB->s3_storage_id)->toBe($this->storageB->id);
|
||||
});
|
||||
|
||||
test('moveBackup on other team backup throws and leaves row unchanged', function () {
|
||||
expect(fn () => Livewire::test(StorageResources::class, ['storage' => $this->storageA])
|
||||
->set('selectedStorages', [$this->backupB->id => $this->storageA->id])
|
||||
->call('moveBackup', $this->backupB->id))
|
||||
->toThrow(ModelNotFoundException::class);
|
||||
|
||||
$this->backupB->refresh();
|
||||
expect($this->backupB->s3_storage_id)->toBe($this->storageB->id);
|
||||
});
|
||||
|
||||
test('disableS3 on own backup succeeds', function () {
|
||||
Livewire::test(StorageResources::class, ['storage' => $this->storageA])
|
||||
->call('disableS3', $this->backupA->id);
|
||||
|
||||
$this->backupA->refresh();
|
||||
expect((bool) $this->backupA->save_s3)->toBeFalse();
|
||||
expect($this->backupA->s3_storage_id)->toBeNull();
|
||||
});
|
||||
});
|
||||
297
tests/Feature/TeamScopedDestinationTest.php
Normal file
297
tests/Feature/TeamScopedDestinationTest.php
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Destination\Show as DestinationShow;
|
||||
use App\Livewire\Project\New\DockerCompose;
|
||||
use App\Livewire\Project\New\DockerImage;
|
||||
use App\Livewire\Project\New\GithubPrivateRepository;
|
||||
use App\Livewire\Project\New\GithubPrivateRepositoryDeployKey;
|
||||
use App\Livewire\Project\New\PublicGitRepository;
|
||||
use App\Livewire\Project\New\SimpleDockerfile;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\SwarmDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Queue::fake();
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
|
||||
|
||||
$this->userA = User::factory()->create();
|
||||
$this->teamA = Team::factory()->create();
|
||||
$this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
|
||||
|
||||
$this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]);
|
||||
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
|
||||
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
|
||||
$this->destinationA = StandaloneDocker::factory()->create([
|
||||
'server_id' => $this->serverA->id,
|
||||
'name' => 'dest-a-'.fake()->unique()->word(),
|
||||
'network' => 'coolify-a-'.fake()->unique()->word(),
|
||||
]);
|
||||
|
||||
$this->userB = User::factory()->create();
|
||||
$this->teamB = Team::factory()->create();
|
||||
$this->userB->teams()->attach($this->teamB, ['role' => 'owner']);
|
||||
|
||||
$this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]);
|
||||
$this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]);
|
||||
$this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]);
|
||||
$this->destinationB = StandaloneDocker::factory()->create([
|
||||
'server_id' => $this->serverB->id,
|
||||
'name' => 'dest-b-'.fake()->unique()->word(),
|
||||
'network' => 'coolify-b-'.fake()->unique()->word(),
|
||||
]);
|
||||
$this->swarmDestinationB = SwarmDocker::create([
|
||||
'uuid' => fake()->uuid(),
|
||||
'name' => 'swarm-b-'.fake()->unique()->word(),
|
||||
'network' => 'swarm-b-'.fake()->unique()->word(),
|
||||
'server_id' => $this->serverB->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($this->userA);
|
||||
session(['currentTeam' => $this->teamA]);
|
||||
});
|
||||
|
||||
describe('find_destination_for_current_team helper', function () {
|
||||
test('returns null for other team destination UUID', function () {
|
||||
expect(find_destination_for_current_team($this->destinationB->uuid))->toBeNull();
|
||||
});
|
||||
|
||||
test('returns null for other team swarm destination UUID', function () {
|
||||
expect(find_destination_for_current_team($this->swarmDestinationB->uuid))->toBeNull();
|
||||
});
|
||||
|
||||
test('returns own team destination', function () {
|
||||
$found = find_destination_for_current_team($this->destinationA->uuid);
|
||||
expect($found)->not->toBeNull();
|
||||
expect($found->id)->toBe($this->destinationA->id);
|
||||
});
|
||||
|
||||
test('returns null for blank uuid', function () {
|
||||
expect(find_destination_for_current_team(null))->toBeNull();
|
||||
expect(find_destination_for_current_team(''))->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SimpleDockerfile destination team scope', function () {
|
||||
test('submit with other team destination throws and creates no application', function () {
|
||||
$routeParams = [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
];
|
||||
request()->headers->set('referer', route('project.resource.create', $routeParams).'?destination='.$this->destinationB->uuid);
|
||||
|
||||
$before = Application::count();
|
||||
|
||||
expect(fn () => Livewire::withUrlParams(['destination' => $this->destinationB->uuid])
|
||||
->test(SimpleDockerfile::class, $routeParams)
|
||||
->set('dockerfile', "FROM nginx\nCMD [\"nginx\"]\n")
|
||||
->call('submit'))
|
||||
->toThrow(Exception::class, 'Destination not found.');
|
||||
|
||||
expect(Application::count())->toBe($before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DockerImage destination team scope', function () {
|
||||
test('submit with other team destination throws and creates no application', function () {
|
||||
$routeParams = [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
];
|
||||
|
||||
$before = Application::count();
|
||||
|
||||
expect(fn () => Livewire::withUrlParams(['destination' => $this->destinationB->uuid])
|
||||
->test(DockerImage::class, $routeParams)
|
||||
->set('imageName', 'nginx')
|
||||
->set('imageTag', 'latest')
|
||||
->call('submit'))
|
||||
->toThrow(Exception::class, 'Destination not found.');
|
||||
|
||||
expect(Application::count())->toBe($before);
|
||||
});
|
||||
|
||||
test('submit with other team swarm destination throws', function () {
|
||||
$routeParams = [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
];
|
||||
|
||||
expect(fn () => Livewire::withUrlParams(['destination' => $this->swarmDestinationB->uuid])
|
||||
->test(DockerImage::class, $routeParams)
|
||||
->set('imageName', 'nginx')
|
||||
->set('imageTag', 'latest')
|
||||
->call('submit'))
|
||||
->toThrow(Exception::class, 'Destination not found.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DockerCompose destination + server_id team scope', function () {
|
||||
test('submit with other team destination throws and creates no service', function () {
|
||||
$routeParams = [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
];
|
||||
|
||||
$before = Service::count();
|
||||
|
||||
Livewire::withUrlParams([
|
||||
'destination' => $this->destinationB->uuid,
|
||||
'server_id' => $this->serverB->id,
|
||||
])
|
||||
->test(DockerCompose::class, $routeParams)
|
||||
->set('dockerComposeRaw', "services:\n app:\n image: nginx\n")
|
||||
->call('submit');
|
||||
|
||||
expect(Service::count())->toBe($before);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('PublicGitRepository destination team scope', function () {
|
||||
test('submit with other team destination creates no application', function () {
|
||||
$routeParams = [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
];
|
||||
|
||||
$before = Application::count();
|
||||
|
||||
try {
|
||||
Livewire::withUrlParams(['destination' => $this->destinationB->uuid])
|
||||
->test(PublicGitRepository::class, $routeParams)
|
||||
->set('repository_url', 'https://github.com/coollabsio/coolify')
|
||||
->set('git_repository', 'coollabsio/coolify')
|
||||
->set('git_branch', 'main')
|
||||
->set('port', 3000)
|
||||
->set('build_pack', 'nixpacks')
|
||||
->set('git_source', 'other')
|
||||
->call('submit');
|
||||
} catch (Throwable $e) {
|
||||
// submit wraps errors via handleError; count assertion below is source of truth
|
||||
}
|
||||
|
||||
expect(Application::count())->toBe($before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GithubPrivateRepository destination team scope', function () {
|
||||
test('submit with other team destination throws and creates no application', function () {
|
||||
$routeParams = [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
];
|
||||
|
||||
$before = Application::count();
|
||||
|
||||
try {
|
||||
Livewire::withUrlParams(['destination' => $this->destinationB->uuid])
|
||||
->test(GithubPrivateRepository::class, $routeParams)
|
||||
->call('submit');
|
||||
} catch (Throwable $e) {
|
||||
// expected
|
||||
}
|
||||
|
||||
expect(Application::count())->toBe($before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GithubPrivateRepositoryDeployKey destination team scope', function () {
|
||||
test('submit with other team destination throws and creates no application', function () {
|
||||
$routeParams = [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
];
|
||||
|
||||
$before = Application::count();
|
||||
|
||||
try {
|
||||
Livewire::withUrlParams(['destination' => $this->destinationB->uuid])
|
||||
->test(GithubPrivateRepositoryDeployKey::class, $routeParams)
|
||||
->call('submit');
|
||||
} catch (Throwable $e) {
|
||||
// expected
|
||||
}
|
||||
|
||||
expect(Application::count())->toBe($before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resource/Create database destination team scope', function () {
|
||||
test('mount with other team destination does not create database', function () {
|
||||
$before = StandalonePostgresql::count();
|
||||
|
||||
$url = route('project.resource.create', [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
]).'?type=postgresql&destination='.$this->destinationB->uuid.'&server_id='.$this->serverB->id.'&database_image=postgres:16-alpine';
|
||||
|
||||
$this->get($url);
|
||||
|
||||
expect(StandalonePostgresql::count())->toBe($before);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('StandaloneDocker/SwarmDocker ownedByCurrentTeam scope', function () {
|
||||
test('StandaloneDocker::ownedByCurrentTeam excludes other team destinations', function () {
|
||||
expect(StandaloneDocker::ownedByCurrentTeam()->where('uuid', $this->destinationB->uuid)->first())->toBeNull();
|
||||
});
|
||||
|
||||
test('SwarmDocker::ownedByCurrentTeam excludes other team destinations', function () {
|
||||
expect(SwarmDocker::ownedByCurrentTeam()->where('uuid', $this->swarmDestinationB->uuid)->first())->toBeNull();
|
||||
});
|
||||
|
||||
test('StandaloneDocker::ownedByCurrentTeam returns own destination', function () {
|
||||
$found = StandaloneDocker::ownedByCurrentTeam()->where('uuid', $this->destinationA->uuid)->first();
|
||||
expect($found)->not->toBeNull();
|
||||
expect($found->id)->toBe($this->destinationA->id);
|
||||
});
|
||||
|
||||
test('StandaloneDocker::ownedByCurrentTeamAPI scopes by explicit team id', function () {
|
||||
expect(StandaloneDocker::ownedByCurrentTeamAPI($this->teamA->id)->where('uuid', $this->destinationB->uuid)->first())->toBeNull();
|
||||
expect(StandaloneDocker::ownedByCurrentTeamAPI($this->teamB->id)->where('uuid', $this->destinationB->uuid)->first()?->id)->toBe($this->destinationB->id);
|
||||
});
|
||||
|
||||
test('SwarmDocker::ownedByCurrentTeamAPI scopes by explicit team id', function () {
|
||||
expect(SwarmDocker::ownedByCurrentTeamAPI($this->teamA->id)->where('uuid', $this->swarmDestinationB->uuid)->first())->toBeNull();
|
||||
expect(SwarmDocker::ownedByCurrentTeamAPI($this->teamB->id)->where('uuid', $this->swarmDestinationB->uuid)->first()?->id)->toBe($this->swarmDestinationB->id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Destination/Show team scope', function () {
|
||||
test('mount with other team destination UUID redirects to index', function () {
|
||||
$component = Livewire::test(DestinationShow::class, ['destination_uuid' => $this->destinationB->uuid]);
|
||||
|
||||
expect($component->get('destination'))->toBeNull();
|
||||
$component->assertRedirect(route('destination.index'));
|
||||
});
|
||||
|
||||
test('mount with own destination UUID loads it', function () {
|
||||
$component = Livewire::test(DestinationShow::class, ['destination_uuid' => $this->destinationA->uuid]);
|
||||
|
||||
expect($component->get('destination'))->not->toBeNull();
|
||||
expect($component->get('destination')->id)->toBe($this->destinationA->id);
|
||||
});
|
||||
|
||||
test('mount with other team swarm destination UUID redirects to index', function () {
|
||||
$component = Livewire::test(DestinationShow::class, ['destination_uuid' => $this->swarmDestinationB->uuid]);
|
||||
|
||||
expect($component->get('destination'))->toBeNull();
|
||||
$component->assertRedirect(route('destination.index'));
|
||||
});
|
||||
});
|
||||
96
tests/Feature/TeamScopedResourceProofsTest.php
Normal file
96
tests/Feature/TeamScopedResourceProofsTest.php
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
// Team A (current actor)
|
||||
$this->userA = User::factory()->create();
|
||||
$this->teamA = Team::factory()->create();
|
||||
$this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
|
||||
$this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]);
|
||||
$this->destinationA = StandaloneDocker::factory()->create(['server_id' => $this->serverA->id, 'network' => 'net-a-'.fake()->uuid()]);
|
||||
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
|
||||
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
|
||||
|
||||
// Team B (other team)
|
||||
$this->userB = User::factory()->create();
|
||||
$this->teamB = Team::factory()->create();
|
||||
$this->userB->teams()->attach($this->teamB, ['role' => 'owner']);
|
||||
$this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]);
|
||||
$this->destinationB = StandaloneDocker::factory()->create(['server_id' => $this->serverB->id, 'network' => 'net-b-'.fake()->uuid()]);
|
||||
$this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]);
|
||||
$this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]);
|
||||
|
||||
// Authenticate as Team A
|
||||
$this->actingAs($this->userA);
|
||||
session(['currentTeam' => $this->teamA]);
|
||||
});
|
||||
|
||||
test('unscoped Project lookup returns another teams project', function () {
|
||||
$project = Project::where('uuid', $this->projectB->uuid)->first();
|
||||
|
||||
expect($project)->not->toBeNull()
|
||||
->and($project->team_id)->toBe($this->teamB->id)
|
||||
->and($project->team_id)->not->toBe($this->teamA->id);
|
||||
});
|
||||
|
||||
test('unscoped StandaloneDocker lookup returns another teams destination', function () {
|
||||
$dest = StandaloneDocker::where('uuid', $this->destinationB->uuid)->first();
|
||||
|
||||
expect($dest)->not->toBeNull()
|
||||
->and($dest->server->team_id)->toBe($this->teamB->id);
|
||||
});
|
||||
|
||||
test('ownedByCurrentTeam scope blocks other-team Project access', function () {
|
||||
expect(Project::ownedByCurrentTeam()->where('uuid', $this->projectB->uuid)->first())->toBeNull();
|
||||
});
|
||||
|
||||
test('ownedByCurrentTeam scope allows own Project access', function () {
|
||||
expect(Project::ownedByCurrentTeam()->where('uuid', $this->projectA->uuid)->first())->not->toBeNull();
|
||||
});
|
||||
|
||||
test('Team A can create Application in Team B environment via unscoped lookups', function () {
|
||||
$destination = StandaloneDocker::where('uuid', $this->destinationB->uuid)->first();
|
||||
$project = Project::where('uuid', $this->projectB->uuid)->first();
|
||||
$environment = $project->load(['environments'])->environments->where('uuid', $this->environmentB->uuid)->first();
|
||||
|
||||
$application = Application::create([
|
||||
'name' => 'team-scope-test-canary',
|
||||
'repository_project_id' => 0,
|
||||
'git_repository' => 'coollabsio/coolify',
|
||||
'git_branch' => 'main',
|
||||
'build_pack' => 'dockerfile',
|
||||
'dockerfile' => "FROM alpine\nCMD echo hello",
|
||||
'ports_exposes' => 80,
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
'health_check_enabled' => false,
|
||||
'source_id' => 0,
|
||||
'source_type' => GithubApp::class,
|
||||
]);
|
||||
|
||||
expect($application->environment_id)->toBe($this->environmentB->id)
|
||||
->and($application->destination_id)->toBe($this->destinationB->id)
|
||||
->and($application->environment->project->team->id)->toBe($this->teamB->id)
|
||||
->and($application->environment->project->team->id)->not->toBe($this->teamA->id);
|
||||
});
|
||||
|
||||
test('resource creation page loads with another teams project UUID', function () {
|
||||
$response = $this->get(route('project.resource.create', [
|
||||
'project_uuid' => $this->projectB->uuid,
|
||||
'environment_uuid' => $this->environmentB->uuid,
|
||||
]));
|
||||
|
||||
expect($response->status())->not->toBe(403);
|
||||
});
|
||||
338
tests/Feature/Webhook/WebhookHmacTest.php
Normal file
338
tests/Feature/Webhook/WebhookHmacTest.php
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function createApplicationWithWebhook(string $repo = 'test-org/test-repo', string $branch = 'main', array $overrides = []): Application
|
||||
{
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$destination = $server->standaloneDockers()->firstOrFail();
|
||||
|
||||
return Application::create(array_merge([
|
||||
'name' => 'webhook-test-app',
|
||||
'git_repository' => "https://github.com/{$repo}",
|
||||
'git_branch' => $branch,
|
||||
'build_pack' => 'nixpacks',
|
||||
'ports_exposes' => '3000',
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
describe('GitHub Manual Webhook HMAC', function () {
|
||||
test('rejects push when secret is empty', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
DB::table('applications')->where('id', $app->id)->update([
|
||||
'manual_webhook_secret_github' => null,
|
||||
]);
|
||||
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
|
||||
'HTTP_X-GitHub-Event' => 'push',
|
||||
'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, ''),
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Webhook secret not configured');
|
||||
});
|
||||
|
||||
test('rejects push with forged hash', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
|
||||
'HTTP_X-GitHub-Event' => 'push',
|
||||
'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Invalid signature');
|
||||
});
|
||||
|
||||
test('accepts push with valid hash', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
$secret = $app->manual_webhook_secret_github;
|
||||
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
$hmac = hash_hmac('sha256', $payload, $secret);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
|
||||
'HTTP_X-GitHub-Event' => 'push',
|
||||
'HTTP_X-Hub-Signature-256' => "sha256={$hmac}",
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
$content = $response->getContent();
|
||||
expect($content)->not->toContain('Invalid signature');
|
||||
expect($content)->not->toContain('Webhook secret not configured');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GitLab Manual Webhook HMAC', function () {
|
||||
test('rejects push when secret is empty', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
DB::table('applications')->where('id', $app->id)->update([
|
||||
'manual_webhook_secret_gitlab' => null,
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/webhooks/source/gitlab/events/manual', [
|
||||
'object_kind' => 'push',
|
||||
'ref' => 'refs/heads/main',
|
||||
'project' => ['path_with_namespace' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
], [
|
||||
'X-Gitlab-Token' => 'attacker-supplied-token',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Webhook secret not configured');
|
||||
});
|
||||
|
||||
test('rejects push with wrong token', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
|
||||
$response = $this->postJson('/webhooks/source/gitlab/events/manual', [
|
||||
'object_kind' => 'push',
|
||||
'ref' => 'refs/heads/main',
|
||||
'project' => ['path_with_namespace' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
], [
|
||||
'X-Gitlab-Token' => 'wrong-token',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Invalid signature');
|
||||
});
|
||||
|
||||
test('accepts push with valid token', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
$secret = $app->manual_webhook_secret_gitlab;
|
||||
|
||||
$response = $this->postJson('/webhooks/source/gitlab/events/manual', [
|
||||
'object_kind' => 'push',
|
||||
'ref' => 'refs/heads/main',
|
||||
'project' => ['path_with_namespace' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
], [
|
||||
'X-Gitlab-Token' => $secret,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$content = $response->getContent();
|
||||
expect($content)->not->toContain('Invalid signature');
|
||||
expect($content)->not->toContain('Webhook secret not configured');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bitbucket Manual Webhook HMAC', function () {
|
||||
test('rejects push when secret is empty', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
DB::table('applications')->where('id', $app->id)->update([
|
||||
'manual_webhook_secret_bitbucket' => null,
|
||||
]);
|
||||
|
||||
$payload = json_encode([
|
||||
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]],
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
]);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [
|
||||
'HTTP_X-Event-Key' => 'repo:push',
|
||||
'HTTP_X-Hub-Signature' => 'sha256='.hash_hmac('sha256', $payload, ''),
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Webhook secret not configured');
|
||||
});
|
||||
|
||||
test('rejects push with non-sha256 algorithm', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
$secret = $app->manual_webhook_secret_bitbucket;
|
||||
|
||||
$payload = json_encode([
|
||||
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]],
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
]);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [
|
||||
'HTTP_X-Event-Key' => 'repo:push',
|
||||
'HTTP_X-Hub-Signature' => 'sha1='.hash_hmac('sha1', $payload, $secret),
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Invalid signature');
|
||||
});
|
||||
|
||||
test('rejects push with forged hash', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
|
||||
$payload = json_encode([
|
||||
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]],
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
]);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [
|
||||
'HTTP_X-Event-Key' => 'repo:push',
|
||||
'HTTP_X-Hub-Signature' => 'sha256=forgedhashvalue',
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Invalid signature');
|
||||
});
|
||||
|
||||
test('accepts push with valid sha256 hash', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
$secret = $app->manual_webhook_secret_bitbucket;
|
||||
|
||||
$payload = json_encode([
|
||||
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]],
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
]);
|
||||
|
||||
$hmac = hash_hmac('sha256', $payload, $secret);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [
|
||||
'HTTP_X-Event-Key' => 'repo:push',
|
||||
'HTTP_X-Hub-Signature' => "sha256={$hmac}",
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
$content = $response->getContent();
|
||||
expect($content)->not->toContain('Invalid signature');
|
||||
expect($content)->not->toContain('Webhook secret not configured');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gitea Manual Webhook HMAC', function () {
|
||||
test('rejects push when secret is empty', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
DB::table('applications')->where('id', $app->id)->update([
|
||||
'manual_webhook_secret_gitea' => null,
|
||||
]);
|
||||
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/gitea/events/manual', [], [], [], [
|
||||
'HTTP_X-Gitea-Event' => 'push',
|
||||
'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, ''),
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Webhook secret not configured');
|
||||
});
|
||||
|
||||
test('rejects push with forged hash', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/gitea/events/manual', [], [], [], [
|
||||
'HTTP_X-Gitea-Event' => 'push',
|
||||
'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Invalid signature');
|
||||
});
|
||||
|
||||
test('accepts push with valid hash', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
$secret = $app->manual_webhook_secret_gitea;
|
||||
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
$hmac = hash_hmac('sha256', $payload, $secret);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/gitea/events/manual', [], [], [], [
|
||||
'HTTP_X-Gitea-Event' => 'push',
|
||||
'HTTP_X-Hub-Signature-256' => "sha256={$hmac}",
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
$content = $response->getContent();
|
||||
expect($content)->not->toContain('Invalid signature');
|
||||
expect($content)->not->toContain('Webhook secret not configured');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Webhook Secret Auto-Generation', function () {
|
||||
test('auto-generates webhook secrets on application creation', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
|
||||
expect($app->manual_webhook_secret_github)->not->toBeEmpty();
|
||||
expect($app->manual_webhook_secret_gitlab)->not->toBeEmpty();
|
||||
expect($app->manual_webhook_secret_bitbucket)->not->toBeEmpty();
|
||||
expect($app->manual_webhook_secret_gitea)->not->toBeEmpty();
|
||||
expect(strlen($app->manual_webhook_secret_github))->toBe(40);
|
||||
expect(strlen($app->manual_webhook_secret_gitlab))->toBe(40);
|
||||
expect(strlen($app->manual_webhook_secret_bitbucket))->toBe(40);
|
||||
expect(strlen($app->manual_webhook_secret_gitea))->toBe(40);
|
||||
});
|
||||
|
||||
test('encrypts webhook secrets at rest', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
$plaintext = $app->manual_webhook_secret_github;
|
||||
|
||||
$raw = DB::table('applications')->where('id', $app->id)->first();
|
||||
|
||||
expect($raw->manual_webhook_secret_github)->not->toBe($plaintext);
|
||||
expect($app->manual_webhook_secret_github)->toBe($plaintext);
|
||||
});
|
||||
});
|
||||
|
|
@ -437,6 +437,16 @@
|
|||
expect($buildImagesToDelete->pluck('tag')->toArray())->toContain('commit1-build');
|
||||
});
|
||||
|
||||
it('container prune excludes persistent resource types', function () {
|
||||
$sourceFile = file_get_contents(__DIR__.'/../../../../app/Actions/Server/CleanupDocker.php');
|
||||
|
||||
expect($sourceFile)->toContain('label!=coolify.type=database');
|
||||
expect($sourceFile)->toContain('label!=coolify.type=application');
|
||||
expect($sourceFile)->toContain('label!=coolify.type=service');
|
||||
expect($sourceFile)->toContain('label!=coolify.proxy=true');
|
||||
expect($sourceFile)->toContain('label=coolify.managed=true');
|
||||
});
|
||||
|
||||
it('preserves build image for currently running tag', function () {
|
||||
$images = collect([
|
||||
['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'],
|
||||
|
|
|
|||
Loading…
Reference in a new issue