false]); Event::fake([BackupCreated::class]); Notification::fake(); InstanceSettings::unguarded(fn () => InstanceSettings::query()->firstOrCreate(['id' => 0], ['is_api_enabled' => true])); InstanceSettings::query()->whereKey(0)->update(['is_api_enabled' => true]); $this->team = Team::factory()->create(); // The notification fake only records notifications that have at least one enabled channel. $this->team->emailNotificationSettings->update(['smtp_enabled' => true, 'backup_failure_email_notifications' => true]); $this->server = Server::factory()->create([ 'team_id' => $this->team->id, 'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id, ]); $this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]); $this->destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail(); $this->environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $this->team->id])->id]); $this->s3 = S3Storage::create([ 'name' => 'Backups', 'region' => 'us-east-1', 'key' => 'redaction-s3-access-key', 'secret' => 'redaction-s3-secret-value', 'bucket' => 'bucket', 'endpoint' => 'https://s3.example.com', 'team_id' => $this->team->id, 'is_usable' => true, ]); }); function redactionMysqlBackup(Team $team, Environment $environment, StandaloneDocker $destination, ?S3Storage $s3 = null): ScheduledDatabaseBackup { $database = create_standalone_mysql($environment->id, $destination, [ 'mysql_root_password' => str_repeat('x', 5), 'mysql_database' => 'app', 'status' => 'running:healthy', ]); return ScheduledDatabaseBackup::create([ 'frequency' => '0 0 * * *', 'save_s3' => $s3 !== null, 's3_storage_id' => $s3?->id, 'database_type' => StandaloneMysql::class, 'database_id' => $database->id, 'team_id' => $team->id, 'databases_to_backup' => 'app', ]); } /** * @return array */ function backupRedactionToken(User $user, Team $team, array $abilities): array { session(['currentTeam' => $team]); $token = $user->createToken('backup-redaction', $abilities); DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update(['team_id' => $team->id]); return ['Authorization' => 'Bearer '.$token->plainTextToken]; } it('redacts the database password from the stored execution and the failure notification', function () { $backup = redactionMysqlBackup($this->team, $this->environment, $this->destination); $password = $backup->database->mysql_root_password; Process::fake(function ($process) use ($password) { $command = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); if (str_contains($command, 'mysqldump')) { return Process::result(errorOutput: "mysqldump: Got error: 1045: Access denied for user 'root' with password {$password}", exitCode: 1); } return Process::result(output: ''); }); (new DatabaseBackupJob($backup))->handle(); $execution = $backup->executions()->latest('id')->firstOrFail(); expect($execution->status)->toBe('failed') ->and($execution->message)->toContain('Access denied') ->and($execution->message)->not->toContain($password); Notification::assertSentTo($this->team, BackupFailed::class, fn (BackupFailed $notification) => str_contains((string) $notification->output, 'Access denied') && ! str_contains((string) $notification->output, $password)); }); it('redacts the S3 key and secret from a permanently failed database backup', function () { $backup = redactionMysqlBackup($this->team, $this->environment, $this->destination, $this->s3); ScheduledDatabaseBackupExecution::create([ 'uuid' => 'redaction-run', 'scheduled_database_backup_id' => $backup->id, 'database_name' => 'app', 'status' => 'running', ]); $job = new DatabaseBackupJob($backup); $job->executionUuidPrefix = 'redaction-run'; $job->failed(new RuntimeException('mc: alias set failed for key redaction-s3-access-key and secret redaction-s3-secret-value')); $message = $backup->executions()->sole()->message; expect($message)->toContain('mc: alias set failed') ->and($message)->not->toContain('redaction-s3-access-key') ->and($message)->not->toContain('redaction-s3-secret-value'); Notification::assertSentTo($this->team, BackupFailed::class, fn (BackupFailed $notification) => ! str_contains((string) $notification->output, 'redaction-s3-secret-value') && ! str_contains((string) $notification->output, 'redaction-s3-access-key')); }); it('redacts the S3 key and secret from a failed volume backup', function () { $application = Application::factory()->create([ 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), ]); $volume = LocalPersistentVolume::create([ 'name' => 'app-data', 'mount_path' => '/data', 'resource_id' => $application->id, 'resource_type' => $application->getMorphClass(), ]); $backup = $volume->scheduledBackups()->create([ 'team_id' => $this->team->id, 's3_storage_id' => $this->s3->id, 'save_s3' => true, 'frequency' => '0 0 * * *', ]); $execution = $backup->executions()->create(['s3_storage_id' => $this->s3->id]); (new VolumeBackupJob($backup))->failed(new RuntimeException('mc: upload failed with secret redaction-s3-secret-value')); expect($execution->fresh()->message)->toContain('mc: upload failed') ->and($execution->fresh()->message)->not->toContain('redaction-s3-secret-value'); Notification::assertSentTo($this->team, VolumeBackupFailed::class, fn (VolumeBackupFailed $notification) => str_contains($notification->output, 'mc: upload failed') && ! str_contains($notification->output, 'redaction-s3-secret-value')); }); it('does not put the telegram bot token into the connection error', function () { Http::fake(['*' => Http::failedConnection()]); try { (new SendMessageToTelegramJob('Message', [], '123456:telegram-bot-secret-token', '42'))->handle(); $this->fail('The connection error was not reported.'); } catch (RuntimeException $exception) { expect($exception->getMessage())->toContain('https://api.telegram.org') ->and($exception->getMessage())->not->toContain('telegram-bot-secret-token') ->and($exception->getPrevious())->toBeNull(); } }); it('does not put the secret webhook path into the connection error', function () { Http::fake(['*' => Http::failedConnection()]); try { (new SendWebhookJob(['event' => 'test'], 'https://example.com/hooks/webhook-secret-path'))->handle(); $this->fail('The connection error was not reported.'); } catch (RuntimeException $exception) { expect($exception->getMessage())->toContain('https://example.com') ->and($exception->getMessage())->not->toContain('webhook-secret-path') ->and($exception->getPrevious())->toBeNull(); } }); it('does not share the provider response body when a channel is disabled', function () { $rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]); foreach ([$rootTeam, $this->team] as $team) { $team->discordNotificationSettings->update(['discord_enabled' => true, 'discord_webhook_url' => 'https://example.com/discord']); } $settings = tap($this->team->telegramNotificationSettings)->update(['telegram_enabled' => true, 'telegram_token' => 'token', 'telegram_chat_id' => '123']); Http::fake(['*' => Http::response(['ok' => false, 'description' => 'Unauthorized provider-body-marker'], 401)]); foreach (range(1, 3) as $attempt) { (new SendMessageToTelegramJob('Message', [], 'token', '123', null, $settings->fresh()))->handle(); } expect($settings->fresh()->telegram_enabled)->toBeFalse(); Notification::assertSentTo($rootTeam, GeneralNotification::class, fn (GeneralNotification $notification) => str_contains($notification->message, 'HTTP 401') && ! str_contains($notification->message, 'provider-body-marker')); Notification::assertSentTo($this->team, GeneralNotification::class, fn (GeneralNotification $notification) => ! str_contains($notification->message, 'provider-body-marker')); }); it('redacts secrets from server validation logs', function () { $this->server->update(['validation_logs' => 'Could not connect: postgres://coolify:validation-db-secret@10.0.0.5/app']); expect($this->server->fresh()->validation_logs)->toContain('Could not connect') ->and($this->server->fresh()->validation_logs)->not->toContain('validation-db-secret'); }); describe('backup executions api', function () { beforeEach(function () { $this->owner = User::factory()->create(); $this->team->members()->attach($this->owner, ['role' => 'owner']); $this->backup = redactionMysqlBackup($this->team, $this->environment, $this->destination); ScheduledDatabaseBackupExecution::create([ 'scheduled_database_backup_id' => $this->backup->id, 'database_name' => 'app', 'status' => 'failed', 'message' => 'dump failed: mysql://root:api-db-secret@db:3306/app', ]); $this->executionsUrl = "/api/v1/databases/{$this->backup->database->uuid}/backups/{$this->backup->uuid}/executions"; $this->backupsUrl = "/api/v1/databases/{$this->backup->database->uuid}/backups"; }); it('hides the execution message from a token without read:sensitive', function () { $headers = backupRedactionToken($this->owner, $this->team, ['read']); $executions = $this->withHeaders($headers)->getJson($this->executionsUrl)->assertOk(); $backups = $this->withHeaders($headers)->getJson($this->backupsUrl)->assertOk(); expect($executions->json('executions.0.status'))->toBe('failed') ->and($executions->json('executions.0.message'))->toBeNull() ->and($backups->json('0.executions.0.message'))->toBeNull() ->and($executions->getContent().$backups->getContent())->not->toContain('dump failed'); }); it('returns the redacted execution message to a token with read:sensitive', function () { $headers = backupRedactionToken($this->owner, $this->team, ['read', 'read:sensitive']); $message = $this->withHeaders($headers)->getJson($this->executionsUrl)->assertOk()->json('executions.0.message'); expect($message)->toContain('dump failed') ->and($message)->not->toContain('api-db-secret'); }); it('does not return executions of another team', function () { $otherTeam = Team::factory()->create(); $otherTeam->members()->attach($this->owner, ['role' => 'owner']); $headers = backupRedactionToken($this->owner, $otherTeam, ['read', 'read:sensitive']); $this->withHeaders($headers)->getJson($this->executionsUrl)->assertNotFound(); }); });