From fcefb0866eb19f0fccbf97317b6d21d9c03fc821 Mon Sep 17 00:00:00 2001 From: ARCANEDEV Date: Thu, 3 Jun 2021 10:45:16 +0100 Subject: [PATCH] Updating the package --- .docker/php/Dockerfile | 3 +- .github/workflows/run-tests.yml | 4 +- composer.json | 11 +- config/backup.php | 66 ++++- docker-compose.yml | 4 +- src/Actions/Action.php | 4 +- src/Actions/Backup/BackupAction.php | 6 +- src/Actions/Backup/BackupPassable.php | 4 +- .../Backup/Tasks/CheckBackupDestinations.php | 4 +- src/Actions/Backup/Tasks/CheckOptions.php | 4 +- src/Actions/Backup/Tasks/CreateBackupFile.php | 4 +- .../Backup/Tasks/CreateTemporaryDirectory.php | 4 +- .../Backup/Tasks/MoveBackupToDisks.php | 4 +- .../Backup/Tasks/PrepareFilesToBackup.php | 20 +- src/Actions/Cleanup/CleanAction.php | 4 +- src/Actions/Cleanup/CleanupPassable.php | 4 +- .../Cleanup/Strategies/CleanupStrategy.php | 4 +- .../Cleanup/Strategies/DefaultStrategy.php | 4 +- .../Cleanup/Tasks/ApplyCleanupStrategy.php | 4 +- .../Cleanup/Tasks/CheckBackupDestinations.php | 4 +- src/Actions/Monitor/HealthCheckFailure.php | 4 +- .../HealthChecks/AbstractHealthCheck.php | 4 +- .../Monitor/HealthChecks/HealthCheckable.php | 4 +- .../Monitor/HealthChecks/IsReachable.php | 4 +- .../Monitor/HealthChecks/MaximumAgeInDays.php | 4 +- .../MaximumStorageInMegabytes.php | 4 +- src/Actions/Monitor/MonitorAction.php | 4 +- src/Actions/Monitor/MonitorPassable.php | 15 +- .../Monitor/Tasks/CheckBackupsHealth.php | 4 +- src/Actions/Passable.php | 4 +- src/Actions/TaskInterface.php | 4 +- src/BackupServiceProvider.php | 10 +- src/Console/CleanupBackupCommand.php | 4 +- src/Console/ListBackupCommand.php | 148 +++++++++++ src/Console/MonitorBackupCommand.php | 4 +- src/Console/RunBackupCommand.php | 4 +- src/Database/Command.php | 7 +- src/Database/Compressors/Bzip2Compressor.php | 38 +++ src/Database/Compressors/GzipCompressor.php | 4 +- src/Database/Contracts/Compressor.php | 4 +- src/Database/{DdDumper.php => DbDumper.php} | 14 +- src/Database/DbDumperManager.php | 19 +- src/Database/Dumpers/AbstractDumper.php | 49 ++-- .../Dumpers/Concerns/HasDbConnection.php | 21 +- src/Database/Dumpers/MongoDbDumper.php | 14 +- src/Database/Dumpers/MySqlDumper.php | 15 +- src/Database/Dumpers/PostgreSqlDumper.php | 4 +- src/Database/Dumpers/SqliteDumper.php | 4 +- src/Entities/Backup.php | 4 +- src/Entities/BackupCollection.php | 4 +- src/Entities/BackupDestination.php | 32 ++- src/Entities/BackupDestinationCollection.php | 4 +- src/Entities/BackupDestinationStatus.php | 4 +- .../BackupDestinationStatusCollection.php | 4 +- src/Entities/Concerns/HasDisk.php | 4 +- src/Entities/Manifest.php | 4 +- src/Entities/Notifiable.php | 12 +- src/Entities/Period.php | 4 +- src/Events/BackupActionHasFailed.php | 9 +- src/Events/BackupActionWasSuccessful.php | 4 +- src/Events/BackupManifestWasCreated.php | 4 +- src/Events/BackupZipWasCreated.php | 4 +- src/Events/CleanupActionHasFailed.php | 4 +- src/Events/CleanupActionWasSuccessful.php | 4 +- src/Events/DumpingDatabase.php | 36 +++ src/Events/HealthyBackupsWasFound.php | 4 +- src/Events/MonitorActionHasFailed.php | 4 +- src/Events/MonitorActionWasSuccessful.php | 4 +- src/Events/UnhealthyBackupsWasFound.php | 4 +- src/Exceptions/CannotCreateDbDumper.php | 4 +- src/Exceptions/CannotSetDatabaseParameter.php | 4 +- src/Exceptions/CannotStartDatabaseDump.php | 4 +- src/Exceptions/DatabaseDumpFailed.php | 4 +- src/Exceptions/InvalidBackupDestination.php | 16 +- src/Exceptions/InvalidDbDriverException.php | 4 +- src/Exceptions/InvalidHealthCheck.php | 4 +- src/Exceptions/InvalidTaskOptions.php | 4 +- src/Exceptions/ZipException.php | 4 +- src/Helpers/FileChecker.php | 4 +- src/Helpers/FilesSelector.php | 4 +- src/Helpers/Format.php | 14 +- src/Helpers/Zip.php | 135 ++++++++-- .../Concerns/HandleNotifications.php | 4 +- src/Listeners/EncryptBackupArchive.php | 47 ++++ .../SendBackupHasFailedNotification.php | 4 +- .../SendBackupWasSuccessfulNotification.php | 4 +- .../SendCleanupHasFailedNotification.php | 4 +- .../SendCleanupWasSuccessfulNotification.php | 4 +- .../SendHealthyBackupWasFoundNotification.php | 4 +- ...endUnhealthyBackupWasFoundNotification.php | 4 +- src/Notifications/AbstractNotification.php | 4 +- .../BackupHasFailedNotification.php | 26 +- .../BackupWasSuccessfulNotification.php | 25 +- src/Notifications/Channels/DiscordChannel.php | 26 ++ .../CleanupHasFailedNotification.php | 23 +- .../CleanupWasSuccessfulNotification.php | 29 ++- .../HealthyBackupsWasFoundNotification.php | 31 ++- src/Notifications/Messages/DiscordMessage.php | 238 ++++++++++++++++++ .../UnhealthyBackupsWasFoundNotification.php | 43 +++- src/Providers/DeferredServiceProvider.php | 4 +- src/Providers/EventServiceProvider.php | 4 +- src/Providers/NotificationServiceProvider.php | 46 ++++ tests/Asserts/AssertZipFile.php | 4 +- tests/BackupServiceProviderTest.php | 4 +- tests/Concerns/HasDisksManipulation.php | 6 +- tests/Concerns/HasFilesManipulation.php | 47 ++-- tests/Console/CleanupBackupCommandTest.php | 4 +- tests/Console/ListBackupCommandTest.php | 26 ++ tests/Console/MonitorBackupCommandTest.php | 4 +- tests/Console/RunBackupCommandTest.php | 48 +++- tests/Database/DbDumperManagerTest.php | 4 +- tests/Database/Dumpers/DumpTestCase.php | 4 +- tests/Database/Dumpers/MongoDbDumperTest.php | 18 +- tests/Database/Dumpers/MySqlDumperTest.php | 43 +++- .../Database/Dumpers/PostgreSqlDumperTest.php | 4 +- tests/Database/Dumpers/SqliteDumperTest.php | 31 ++- .../BackupDestinationCollectionTest.php | 4 +- tests/Entities/BackupDestinationTest.php | 10 +- tests/Entities/BackupTest.php | 4 +- tests/Entities/BackupTestCase.php | 4 +- tests/Entities/ManifestTest.php | 13 +- .../BackupEventSubscriberTest.php | 5 +- tests/Helpers/FileSelectorTest.php | 10 +- tests/Helpers/FormatTest.php | 4 +- tests/Helpers/ZipTest.php | 18 +- tests/Listeners/EncryptBackupArchiveTest.php | 159 ++++++++++++ .../Providers/DeferredServiceProviderTest.php | 4 +- tests/TestCase.php | 9 +- tests/_stubs/files/archive.zip | Bin 0 -> 583 bytes 129 files changed, 1522 insertions(+), 489 deletions(-) create mode 100644 src/Console/ListBackupCommand.php create mode 100644 src/Database/Compressors/Bzip2Compressor.php rename src/Database/{DdDumper.php => DbDumper.php} (92%) create mode 100644 src/Events/DumpingDatabase.php create mode 100644 src/Listeners/EncryptBackupArchive.php create mode 100644 src/Notifications/Channels/DiscordChannel.php create mode 100644 src/Notifications/Messages/DiscordMessage.php create mode 100644 src/Providers/NotificationServiceProvider.php create mode 100644 tests/Console/ListBackupCommandTest.php create mode 100644 tests/Listeners/EncryptBackupArchiveTest.php create mode 100644 tests/_stubs/files/archive.zip diff --git a/.docker/php/Dockerfile b/.docker/php/Dockerfile index 082a732..3b11847 100644 --- a/.docker/php/Dockerfile +++ b/.docker/php/Dockerfile @@ -1,4 +1,4 @@ -FROM php:7-cli-alpine +FROM php:7.3-cli-alpine # Cleaning... RUN rm -rf /var/cache/apk/* /tmp/* @@ -14,6 +14,7 @@ RUN apk add --no-cache \ # Installing PHP Extensions RUN docker-php-ext-install \ mysqli \ + pcntl \ pdo_mysql \ zip diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 6ec8f3b..6239bcb 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest strategy: - fail-fast: true + fail-fast: false matrix: php: [7.3, 7.4, 8.0] dependency-version: [prefer-lowest, prefer-stable] @@ -17,6 +17,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v2 + with: + fetch-depth: 2 - name: Install SQLite 3 run: | diff --git a/composer.json b/composer.json index b62592a..f014411 100644 --- a/composer.json +++ b/composer.json @@ -18,14 +18,16 @@ "ext-json": "*", "ext-zip": "*", "arcanedev/support": "^8.0", - "league/flysystem": "^1.1" + "league/flysystem": "^1.1", + "symfony/process": "^4.2|^5.0" }, "require-dev": { "aws/aws-sdk-php": "^3.155", + "laravel/framework": "^v8.40", "laravel/slack-notification-channel": "^2.3", "league/flysystem-aws-s3-v3": "^1.0.1", "mockery/mockery": "^1.4.2", - "orchestra/testbench": "^6.4", + "orchestra/testbench-core": "^6.4", "phpunit/phpunit": "^9.3.3" }, "autoload": { @@ -39,8 +41,9 @@ } }, "scripts": { - "test": "phpunit", - "coverage": "phpunit --coverage-html build/coverage/html" + "test": "phpunit --colors=always", + "test:dox": "phpunit --testdox --colors=always", + "test:cov": "phpunit --coverage-html coverage" }, "extra": { "branch-alias": { diff --git a/config/backup.php b/config/backup.php index 821995a..d68d425 100644 --- a/config/backup.php +++ b/config/backup.php @@ -61,6 +61,13 @@ // Determines if it should avoid unreadable folders. 'ignore-unreadable-directories' => false, + /* + * This path is used to make directories in resulting zip-file relative + * Set to `null` to include complete absolute path + * Example: base_path() + */ + 'relative-path' => null, + ], /* @@ -99,22 +106,47 @@ ], - /* - * The database dump can be compressed to decrease disk space usage. - * - * Out of the box LaravelBackup supplies: - * Arcanedev\LaravelBackup\Database\Compressors\GzipCompressor::class - * - * You can also use a custom compressor by implementing the contract: - * Arcanedev\LaravelBackup\Database\Contracts\Compressor - * - * If you do not want any compressor at all, set it to `null`. - */ - 'db-dump-compressor' => null, + 'db-dump' => [ + /* + * The database dump can be compressed to decrease disk space usage. + * + * Out of the box LaravelBackup supplies: + * Arcanedev\LaravelBackup\Database\Compressors\GzipCompressor::class + * + * You can also use a custom compressor by implementing the contract: + * Arcanedev\LaravelBackup\Database\Contracts\Compressor + * + * If you do not want any compressor at all, set it to `null`. + */ + 'compressor' => null, + + /* + * The file extension used for the database dump files. + * + * If not specified, the file extension will be `.archive` for MongoDB and `.sql` for all other databases + * The file extension should be specified without a leading `.` + */ + 'file-extension' => '', + ], // The directory where the temporary files will be stored. 'temporary-directory' => storage_path('app/_backup-temp'), + /* + * The password to be used for archive encryption. + * Set to `null` to disable encryption. + */ + 'password' => env('BACKUP_ARCHIVE_PASSWORD'), + + /* + * The encryption algorithm to be used for archive encryption. + * You can set it to `null` or `false` to disable encryption. + * + * When set to 'default', we'll use ZipArchive::EM_AES_256 if it is + * available on your system. + */ + 'encryption' => 'default', + 'tasks' => [ Arcanedev\LaravelBackup\Actions\Backup\Tasks\CheckOptions::class, Arcanedev\LaravelBackup\Actions\Backup\Tasks\CheckBackupDestinations::class, @@ -262,6 +294,13 @@ 'icon' => null, ], + 'discord' => [ + 'webhook_url' => '', + + 'username' => null, + 'avatar_url' => null, + ], + ], /* ----------------------------------------------------------------- @@ -278,6 +317,9 @@ Arcanedev\LaravelBackup\Events\BackupActionHasFailed::class => [ Arcanedev\LaravelBackup\Listeners\SendBackupHasFailedNotification::class ], + Arcanedev\LaravelBackup\Events\BackupZipWasCreated::class => [ + Arcanedev\LaravelBackup\Listeners\EncryptBackupArchive::class, + ], // Cleanup Action Arcanedev\LaravelBackup\Events\CleanupActionWasSuccessful::class => [ diff --git a/docker-compose.yml b/docker-compose.yml index 96c3c0a..7f8207c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: "3" services: php: - container_name: php_cli + container_name: laravel_backup_php build: ./.docker/php tty: true volumes: @@ -18,7 +18,7 @@ services: mysql: image: mysql:5.7 - container_name: php_mysql + container_name: laravel_backup_mysql volumes: - db_data:/var/lib/mysql ports: diff --git a/src/Actions/Action.php b/src/Actions/Action.php index 1967ecb..eb1bdf2 100644 --- a/src/Actions/Action.php +++ b/src/Actions/Action.php @@ -1,6 +1,4 @@ -selector = $selector; $this->dbDumper = $dbDumper; @@ -83,16 +81,14 @@ public function handle($passable, Closure $next) * * @return \Arcanedev\LaravelBackup\Entities\Manifest */ - protected function createManifest(BackupPassable $passable) + protected function createManifest(BackupPassable $passable): Manifest { return tap(Manifest::make($passable->temporaryDirectoryPath()), function (Manifest $manifest) use ($passable) { - if ( ! $passable->isOnlyDatabases()) { + if ( ! $passable->isOnlyDatabases()) $manifest->addFiles(['files' => $this->selectedFiles($passable)]); - } - if ( ! $passable->isOnlyFiles()) { + if ( ! $passable->isOnlyFiles()) $manifest->addFiles(['databases' => $this->dumpDatabases($passable)]); - } $manifest->save(); }); diff --git a/src/Actions/Cleanup/CleanAction.php b/src/Actions/Cleanup/CleanAction.php index 8f92c35..ac85b36 100644 --- a/src/Actions/Cleanup/CleanAction.php +++ b/src/Actions/Cleanup/CleanAction.php @@ -1,6 +1,4 @@ -getHealthyStatuses()->merge($this->getUnhealthyStatuses()); + } + /** * Get the healthy statuses. * diff --git a/src/Actions/Monitor/Tasks/CheckBackupsHealth.php b/src/Actions/Monitor/Tasks/CheckBackupsHealth.php index 675adc3..c9144d3 100644 --- a/src/Actions/Monitor/Tasks/CheckBackupsHealth.php +++ b/src/Actions/Monitor/Tasks/CheckBackupsHealth.php @@ -1,6 +1,4 @@ -singleton(Database\DbDumperManager::class); $this->singleton(Actions\Cleanup\Strategies\CleanupStrategy::class, Actions\Cleanup\Strategies\DefaultStrategy::class); - $this->registerProvider(Providers\EventServiceProvider::class); + $this->registerProviders([ + Providers\EventServiceProvider::class, + Providers\NotificationServiceProvider::class + ]); $this->registerCommands([ Console\RunBackupCommand::class, Console\CleanupBackupCommand::class, Console\MonitorBackupCommand::class, + Console\ListBackupCommand::class, ]); } diff --git a/src/Console/CleanupBackupCommand.php b/src/Console/CleanupBackupCommand.php index 01f0225..b03e8a2 100644 --- a/src/Console/CleanupBackupCommand.php +++ b/src/Console/CleanupBackupCommand.php @@ -1,6 +1,4 @@ - + */ +class ListBackupCommand extends Command +{ + /* ----------------------------------------------------------------- + | Properties + | ----------------------------------------------------------------- + */ + + /** @var string */ + protected $signature = 'backup:list'; + + /** @var string */ + protected $description = 'Display a list of all backups.'; + + /* ----------------------------------------------------------------- + | Main Methods + | ----------------------------------------------------------------- + */ + + public function handle(MonitorAction $action) + { + $options = Arr::only($this->options(), [ + 'disable-notifications', + ]); + + /** @var \Arcanedev\LaravelBackup\Actions\Monitor\MonitorPassable $passable */ + $passable = $action->execute($options); + + $statuses = $passable->getAllStatuses(); + + $this->displayOverview($statuses); + $this->displayFailures($statuses); + } + + /* ----------------------------------------------------------------- + | Other Methods + | ----------------------------------------------------------------- + */ + + /** + * @param \Illuminate\Support\Collection $backupDestinationStatuses + */ + protected function displayOverview(Collection $backupDestinationStatuses): void + { + $headers = ['Name', 'Disk', 'Reachable', 'Healthy', '# of backups', 'Newest backup', 'Used storage']; + + $rows = $backupDestinationStatuses->map(function (BackupDestinationStatus $backupDestinationStatus) { + return $this->convertToRow($backupDestinationStatus); + }); + + $this->table($headers, $rows, 'default', [ + 4 => static::rightAlignedTableStyle(), + 6 => static::rightAlignedTableStyle(), + ]); + } + + protected static function rightAlignedTableStyle(): TableStyle + { + return (new TableStyle)->setPadType(STR_PAD_LEFT); + } + + /** + * @param \Arcanedev\LaravelBackup\Entities\BackupDestinationStatus $backupDestinationStatus + * + * @return array + */ + protected function convertToRow(BackupDestinationStatus $backupDestinationStatus): array + { + $destination = $backupDestinationStatus->backupDestination(); + + $row = [ + $destination->backupName(), + 'disk' => $destination->diskName(), + Format::statusEmoji($destination->isReachable()), + Format::statusEmoji($backupDestinationStatus->isHealthy()), + 'amount' => $destination->backups()->count(), + 'newest' => $this->getFormattedBackupDate($destination->newestBackup()), + 'usedStorage' => Format::humanReadableSize($destination->usedStorage()), + ]; + + if ( ! $destination->isReachable()) { + foreach (['amount', 'newest', 'usedStorage'] as $propertyName) { + $row[$propertyName] = '/'; + } + } + + if ($backupDestinationStatus->getHealthCheckFailure() !== null) { + $row['disk'] = ''.$row['disk'].''; + } + + return $row; + } + + /** + * @param \Illuminate\Support\Collection $backupDestinationStatuses + */ + protected function displayFailures(Collection $backupDestinationStatuses): void + { + $failed = $backupDestinationStatuses + ->filter(function (BackupDestinationStatus $backupDestinationStatus) { + return $backupDestinationStatus->getHealthCheckFailure() !== null; + }) + ->map(function (BackupDestinationStatus $backupDestinationStatus) { + return [ + $backupDestinationStatus->backupDestination()->backupName(), + $backupDestinationStatus->backupDestination()->diskName(), + $backupDestinationStatus->getHealthCheckFailure()->healthCheck()->name(), + $backupDestinationStatus->getHealthCheckFailure()->exception()->getMessage(), + ]; + }); + + if ($failed->isNotEmpty()) { + $this->warn(''); + $this->warn('Unhealthy backup destinations'); + $this->warn('-----------------------------'); + $this->table(['Name', 'Disk', 'Failed check', 'Description'], $failed->all()); + } + } + + /** + * @param \Arcanedev\LaravelBackup\Entities\Backup|null $backup + * + * @return string + */ + protected function getFormattedBackupDate(Backup $backup = null): string + { + return is_null($backup) + ? 'No backups present' + : Format::ageInDays($backup->date()); + } +} diff --git a/src/Console/MonitorBackupCommand.php b/src/Console/MonitorBackupCommand.php index f4d1855..8d2a0d9 100644 --- a/src/Console/MonitorBackupCommand.php +++ b/src/Console/MonitorBackupCommand.php @@ -1,6 +1,4 @@ -toString(); - if (is_null($compressor)) { + if (is_null($compressor)) return "{$command} > {$dumpFile}"; - } return static::isWindowsOS() ? "{$command} | {$compressor->useCommand()} > {$dumpFile}" diff --git a/src/Database/Compressors/Bzip2Compressor.php b/src/Database/Compressors/Bzip2Compressor.php new file mode 100644 index 0000000..2951585 --- /dev/null +++ b/src/Database/Compressors/Bzip2Compressor.php @@ -0,0 +1,38 @@ + + */ +class Bzip2Compressor implements Compressor +{ + /* ----------------------------------------------------------------- + | Getters + | ----------------------------------------------------------------- + */ + + /** + * Get the command name. + * + * @return string + */ + public function useCommand(): string + { + return 'bzip2'; + } + + /** + * Get the extension. + * + * @return string + */ + public function usedExtension(): string + { + return 'bz2'; + } +} diff --git a/src/Database/Compressors/GzipCompressor.php b/src/Database/Compressors/GzipCompressor.php index 1e4fd64..42f526d 100644 --- a/src/Database/Compressors/GzipCompressor.php +++ b/src/Database/Compressors/GzipCompressor.php @@ -1,6 +1,4 @@ - */ -class DdDumper +class DbDumper { /* ----------------------------------------------------------------- | Properties @@ -85,13 +85,15 @@ public function dump(string $connection, string $filename = null): string { $filename = $filename ?: $connection; - $path = $this->path().DIRECTORY_SEPARATOR."dump-{$filename}.sql"; - $dumper = $this->manager->dumper($connection); + $path = $this->path().DIRECTORY_SEPARATOR."dump-{$filename}.{$dumper->usedExtension()}"; + if ($compressor = $dumper->getCompressor()) $path .= ".{$compressor->usedExtension()}"; + event(new DumpingDatabase($dumper)); + $dumper->dump($path); return $path; diff --git a/src/Database/DbDumperManager.php b/src/Database/DbDumperManager.php index 7a932a9..9694a1f 100644 --- a/src/Database/DbDumperManager.php +++ b/src/Database/DbDumperManager.php @@ -1,23 +1,14 @@ -app['config']['backup.backup.db-dump-compressor']) { + if ($compressor = $this->app['config']['backup.backup.db-dump.compressor']) { $dumper->setCompressor($this->app->make($compressor)); } }); diff --git a/src/Database/Dumpers/AbstractDumper.php b/src/Database/Dumpers/AbstractDumper.php index a359206..f30056a 100644 --- a/src/Database/Dumpers/AbstractDumper.php +++ b/src/Database/Dumpers/AbstractDumper.php @@ -1,6 +1,4 @@ -timeout = $timeout; @@ -120,9 +118,9 @@ public function getCompressor(): ?Compressor * * @param \Arcanedev\LaravelBackup\Database\Contracts\Compressor $compressor * - * @return $this|mixed + * @return $this */ - public function useCompressor(Compressor $compressor) + public function useCompressor(Compressor $compressor): self { return $this->setCompressor($compressor); } @@ -132,15 +130,39 @@ public function useCompressor(Compressor $compressor) * * @param \Arcanedev\LaravelBackup\Database\Contracts\Compressor $compressor * - * @return $this|mixed + * @return $this */ - public function setCompressor(Compressor $compressor) + public function setCompressor(Compressor $compressor): self { $this->compressor = $compressor; return $this; } + /** + * Get the dump file extension. + * + * @return string + */ + public function getExtension(): string + { + return 'sql'; + } + + /** + * Get the used dump file extension. + * + * @return string + */ + public function usedExtension(): string + { + $extension = config('backup.backup.db-dump.file-extension'); + + return empty($extension) + ? $this->getExtension() + : $extension; + } + /* ----------------------------------------------------------------- | Main Methods | ----------------------------------------------------------------- @@ -166,17 +188,14 @@ abstract public function dump(string $dumpFile): void; */ protected static function checkIfDumpWasSuccessFul(Process $process, string $outputFile) { - if ( ! $process->isSuccessful()) { + if ( ! $process->isSuccessful()) throw DatabaseDumpFailed::processDidNotEndSuccessfully($process); - } - if ( ! file_exists($outputFile)) { + if ( ! file_exists($outputFile)) throw DatabaseDumpFailed::dumpfileWasNotCreated(); - } - if (filesize($outputFile) === 0) { + if (filesize($outputFile) === 0) throw DatabaseDumpFailed::dumpfileWasEmpty(); - } } /** diff --git a/src/Database/Dumpers/Concerns/HasDbConnection.php b/src/Database/Dumpers/Concerns/HasDbConnection.php index 1ce1c51..4f65ba5 100644 --- a/src/Database/Dumpers/Concerns/HasDbConnection.php +++ b/src/Database/Dumpers/Concerns/HasDbConnection.php @@ -1,6 +1,4 @@ -extraOptionsAfterDbName[] = $extraOptionAfterDbName; + } + + return $this; + } } diff --git a/src/Database/Dumpers/MongoDbDumper.php b/src/Database/Dumpers/MongoDbDumper.php index bb9d606..4ea80af 100644 --- a/src/Database/Dumpers/MongoDbDumper.php +++ b/src/Database/Dumpers/MongoDbDumper.php @@ -1,6 +1,4 @@ -addIf($this->setGtidPurged !== 'AUTO', "--set-gtid-purged={$this->setGtidPurged}") ->addUnless($this->dbNameWasSetAsExtraOption, $this->getDbName() ?: '') ->addUnless(empty($this->includeTables), '--tables '.implode(' ', $this->includeTables)) + ->addMany($this->extraOptionsAfterDbName) ->echoToFile($dumpFile, $this->getCompressor()); } @@ -319,13 +318,17 @@ public function addExtraOption(string $extraOption) */ public function getDbCredentials(): string { - return implode(PHP_EOL, [ + $contents = [ '[client]', "user = '{$this->getUsername()}'", "password = '{$this->getPassword()}'", - "host = '{$this->getHost()}'", "port = '{$this->getPort()}'", - ]); + ]; + + if (empty($this->getSocket())) + $contents[] = "host = '{$this->getHost()}'"; + + return implode(PHP_EOL, $contents); } /** diff --git a/src/Database/Dumpers/PostgreSqlDumper.php b/src/Database/Dumpers/PostgreSqlDumper.php index e04ec90..4e4b6bd 100644 --- a/src/Database/Dumpers/PostgreSqlDumper.php +++ b/src/Database/Dumpers/PostgreSqlDumper.php @@ -1,6 +1,4 @@ -backupName = preg_replace('/[^a-zA-Z0-9.]/', '-', $backupName); + $this->backupName = (string) preg_replace('/[^a-zA-Z0-9.]/', '-', $backupName); return $this; } @@ -250,6 +248,10 @@ public static function makeFromDiskName(string $diskName, string $backupName): s */ public function write(string $file): void { + if ( ! is_null($this->connectionError)) { + throw InvalidBackupDestination::connectionError($this->diskName); + } + if ( ! $this->hasDisk()) { throw InvalidBackupDestination::diskNotSet($this->backupName); } @@ -286,7 +288,7 @@ public function fresh(): self * * @return bool */ - public function hasCachedBackups() + public function hasCachedBackups(): bool { return ! is_null($this->cachedBackups); } @@ -323,8 +325,10 @@ public function isReachable(): bool */ public function newestBackupIsOlderThan(DateTimeInterface $date): bool { - return is_null($newestBackup = $this->newestBackup()) - ?: $newestBackup->date()->gt($date); + $newestBackup = $this->newestBackup(); + + return is_null($newestBackup) + || $newestBackup->date()->gt($date); } /* ----------------------------------------------------------------- @@ -339,10 +343,16 @@ public function newestBackupIsOlderThan(DateTimeInterface $date): bool */ private function fetchBackups(): BackupCollection { - return BackupCollection::makeFromFiles( - $this->disk(), - $this->hasDisk() ? $this->getFiles() : [] - ); + $files = []; + + if ($this->hasDisk()) { + try { + $files = $this->disk->allFiles($this->backupName); + } + catch (Exception $e) {} + } + + return BackupCollection::makeFromFiles($this->disk(), $files); } /** diff --git a/src/Entities/BackupDestinationCollection.php b/src/Entities/BackupDestinationCollection.php index 6b0f92e..e1fbc2c 100644 --- a/src/Entities/BackupDestinationCollection.php +++ b/src/Entities/BackupDestinationCollection.php @@ -1,6 +1,4 @@ -passable = $passable; $this->exception = $exception; diff --git a/src/Events/BackupActionWasSuccessful.php b/src/Events/BackupActionWasSuccessful.php index ccc7f80..765dcb4 100644 --- a/src/Events/BackupActionWasSuccessful.php +++ b/src/Events/BackupActionWasSuccessful.php @@ -1,6 +1,4 @@ - + */ +class DumpingDatabase +{ + /* ----------------------------------------------------------------- + | Properties + | ----------------------------------------------------------------- + */ + + /** @var \Arcanedev\LaravelBackup\Database\Dumpers\AbstractDumper */ + public $dumper; + + /* ----------------------------------------------------------------- + | Constructor + | ----------------------------------------------------------------- + */ + + /** + * DumpingDatabase constructor. + * + * @param \Arcanedev\LaravelBackup\Database\Dumpers\AbstractDumper $dumper + */ + public function __construct(AbstractDumper $dumper) + { + $this->dumper = $dumper; + } +} diff --git a/src/Events/HealthyBackupsWasFound.php b/src/Events/HealthyBackupsWasFound.php index aa05dfd..0d5431b 100644 --- a/src/Events/HealthyBackupsWasFound.php +++ b/src/Events/HealthyBackupsWasFound.php @@ -1,6 +1,4 @@ - $diskName]) + ); + } + /** * @param string $backupName * diff --git a/src/Exceptions/InvalidDbDriverException.php b/src/Exceptions/InvalidDbDriverException.php index dc9c30b..521f75d 100644 --- a/src/Exceptions/InvalidDbDriverException.php +++ b/src/Exceptions/InvalidDbDriverException.php @@ -1,6 +1,4 @@ -diffInMinutes() / (24 * 60), 2), 2).' ('.$date->diffForHumans().')'; } + + /** + * @param bool $bool + * + * @return string + */ + public static function statusEmoji(bool $bool): string + { + return $bool ? '✅' : '❌'; + } } diff --git a/src/Helpers/Zip.php b/src/Helpers/Zip.php index 4473b07..69b59c1 100644 --- a/src/Helpers/Zip.php +++ b/src/Helpers/Zip.php @@ -1,6 +1,4 @@ -zipArchive = $zipArchive; @@ -106,7 +107,7 @@ public function path(): string * * @return $this */ - public function setPath(string $path) + public function setPath(string $path): self { $this->path = $path; @@ -145,9 +146,9 @@ public function count(): int /** * Create a zip file. * - * @return mixed + * @return $this */ - public function create() + public function create(): Zip { return tap($this->open(ZipArchive::CREATE | ZipArchive::OVERWRITE), function() { $this->fileCount = 0; @@ -159,11 +160,13 @@ public function create() * * @param int|null $flags * - * @return mixed + * @return $this */ - public function open(int $flags = ZipArchive::CREATE) + public function open(int $flags = ZipArchive::CREATE): Zip { - return $this->zipArchive()->open($this->path(), $flags); + $this->isOpened = $this->zipArchive()->open($this->path(), $flags); + + return $this; } /** @@ -173,7 +176,10 @@ public function open(int $flags = ZipArchive::CREATE) */ public function close(): bool { - return $this->zipArchive()->close(); + if ($closed = $this->zipArchive()->close()) + $this->isOpened = false; + + return $closed; } /** @@ -232,27 +238,79 @@ public static function getFiles(string $path): array $files = []; $zip = new static($path); - if ($zip->open() === true) { - for ($i = 0; $i < $zip->numFiles; $i++) { - $name = $zip->getNameIndex($i); + if ($zip->open()->isOpened() !== true) + return $files; - if ($name === false) { - throw ZipException::makeFromStatus($zip->status); - } + for ($i = 0; $i < $zip->numFiles; $i++) { + $name = $zip->getNameIndex($i); - array_push($files, $name); + if ($name === false) { + throw ZipException::makeFromStatus($zip->status); } - $zip->close(); + + array_push($files, $name); } + $zip->close(); return $files; } + /** + * Encrypt the archive. + * + * @return $this + */ + public function encrypt(): Zip + { + if ( ! $this->isOpened()) + return $this; + + if ( ! $this->shouldEncrypt()) + return $this; + + $zip = $this->zipArchive(); + $zip->setPassword($this->getEncryptPassword()); + + foreach (range(0, $zip->numFiles - 1) as $i) { + $zip->setEncryptionIndex($i, $this->getEncryptAlgorithm()); + } + + return $this; + } + /* ----------------------------------------------------------------- | Other Methods | ----------------------------------------------------------------- */ + /** + * Check if the archive was opened. + * + * @return bool + */ + public function isOpened(): bool + { + return $this->isOpened; + } + + /** + * Check if it should encrypt the archive. + * + * @return bool + */ + public function shouldEncrypt(): bool + { + if ($this->getEncryptPassword() === null) + return false; + + $algorithm = $this->getEncryptAlgorithm(); + + if ($algorithm === null || $algorithm == false) + return false; + + return true; + } + /** * Guess the filename used in the zip archive. * @@ -263,10 +321,17 @@ public static function getFiles(string $path): array public function guessFilenameInArchive(string $pathToFile): string { $fileDirectory = dirname($pathToFile); - $zipDirectory = [ + $relativePath = config('backup.backup.source.files.relative-path'); + + if ( ! is_null($relativePath)) { + $relativePath = str_replace(['\\', '/'], DIRECTORY_SEPARATOR, $relativePath); + } + + $zipDirectory = array_filter([ dirname($this->path()), + $relativePath, base_path(), - ]; + ]); $pathToFile = Str::startsWith($fileDirectory, $zipDirectory) ? str_replace($zipDirectory, '', $pathToFile) @@ -275,6 +340,34 @@ public function guessFilenameInArchive(string $pathToFile): string return trim($pathToFile, DIRECTORY_SEPARATOR); } + /** + * Get the encryption password. + * + * @return string|null + */ + protected function getEncryptPassword(): ?string + { + return config('backup.backup.password'); + } + + /** + * Get the encryption algorithm. + * + * @return int|null + */ + public function getEncryptAlgorithm(): ?int + { + $encryption = config('backup.backup.encryption'); + + if ($encryption !== 'default') + return $encryption; + + if (defined("\ZipArchive::EM_AES_256")) + return ZipArchive::EM_AES_256; + + return null; + } + /** * Get a property from the zip archive instance. * diff --git a/src/Listeners/Concerns/HandleNotifications.php b/src/Listeners/Concerns/HandleNotifications.php index b49c8ab..34bc4b5 100644 --- a/src/Listeners/Concerns/HandleNotifications.php +++ b/src/Listeners/Concerns/HandleNotifications.php @@ -1,6 +1,4 @@ - + */ +class EncryptBackupArchive +{ + /* ----------------------------------------------------------------- + | Main Methods + | ----------------------------------------------------------------- + */ + + /** + * Handle the event. + * + * @param \Arcanedev\LaravelBackup\Events\BackupZipWasCreated $event + */ + public function handle(BackupZipWasCreated $event): void + { + $this->encrypt($event->zip); + } + + /** + * Encrypt the archive. + * + * @param \Arcanedev\LaravelBackup\Helpers\Zip $zip + */ + protected function encrypt(Zip $zip): void + { + $wasClosed = ! $zip->isOpened(); + + if ($wasClosed) + $zip->open(); + + $zip->encrypt(); + + if ($wasClosed && $zip->isOpened()) + $zip->close(); + } +} diff --git a/src/Listeners/SendBackupHasFailedNotification.php b/src/Listeners/SendBackupHasFailedNotification.php index 5ed2e97..b2536cb 100644 --- a/src/Listeners/SendBackupHasFailedNotification.php +++ b/src/Listeners/SendBackupHasFailedNotification.php @@ -1,6 +1,4 @@ -error() + ->from( + config('backup.notifications.discord.username'), + config('backup.notifications.discord.avatar_url') + ) + ->title(__('Failed backup of :application_name', [ + 'application_name' => $applicationName = $this->applicationName(), + ])) + ->fields([ + trans('backup::notifications.exception_message_title') => $this->event->exception->getMessage(), + ]); + } } diff --git a/src/Notifications/BackupWasSuccessfulNotification.php b/src/Notifications/BackupWasSuccessfulNotification.php index 0a41922..1744edb 100644 --- a/src/Notifications/BackupWasSuccessfulNotification.php +++ b/src/Notifications/BackupWasSuccessfulNotification.php @@ -1,10 +1,9 @@ -success() + ->from( + config('backup.notifications.discord.username'), + config('backup.notifications.discord.avatar_url') + ) + ->title(__('Successful new backup!')); + + $this->getBackupDestinations()->each(function (BackupDestination $destination) use ($message) { + $message->fields($this->backupDestinationProperties($destination)->toArray()); + }); + + return $message; + } } diff --git a/src/Notifications/Channels/DiscordChannel.php b/src/Notifications/Channels/DiscordChannel.php new file mode 100644 index 0000000..06dc792 --- /dev/null +++ b/src/Notifications/Channels/DiscordChannel.php @@ -0,0 +1,26 @@ + + */ +class DiscordChannel +{ + /** + * @param \Arcanedev\LaravelBackup\Entities\Notifiable $notifiable + * @param \Illuminate\Notifications\Notification|mixed $notification + */ + public function send($notifiable, Notification $notification): void + { + Http::post( + $notifiable->routeNotificationForDiscord(), + $notification->toDiscord()->toArray() + ); + } +} diff --git a/src/Notifications/CleanupHasFailedNotification.php b/src/Notifications/CleanupHasFailedNotification.php index f58ff52..e1efcec 100644 --- a/src/Notifications/CleanupHasFailedNotification.php +++ b/src/Notifications/CleanupHasFailedNotification.php @@ -1,10 +1,9 @@ -error() + ->from( + config('backup.notifications.discord.username'), + config('backup.notifications.discord.avatar_url') + ) + ->title( + __('Cleaning up the backups of :application_name failed.', [ + 'application_name' => $this->applicationName(), + ]) + ) + ->fields([ + trans('backup::notifications.exception_message_title') => $this->event->exception->getMessage(), + ]); + } } diff --git a/src/Notifications/CleanupWasSuccessfulNotification.php b/src/Notifications/CleanupWasSuccessfulNotification.php index 7d37c50..32f0a8d 100644 --- a/src/Notifications/CleanupWasSuccessfulNotification.php +++ b/src/Notifications/CleanupWasSuccessfulNotification.php @@ -1,10 +1,9 @@ -success() + ->from( + config('backup.notifications.discord.username'), + config('backup.notifications.discord.avatar_url') + ) + ->title(__('Clean up of backups successful!')); + + $this->getBackupDestinations()->each(function (BackupDestination $destination) use ($message) { + $message->fields( + $this->backupDestinationProperties($destination)->toArray() + ); + }); + + return $message; + } } diff --git a/src/Notifications/HealthyBackupsWasFoundNotification.php b/src/Notifications/HealthyBackupsWasFoundNotification.php index 02da371..245e808 100644 --- a/src/Notifications/HealthyBackupsWasFoundNotification.php +++ b/src/Notifications/HealthyBackupsWasFoundNotification.php @@ -1,10 +1,9 @@ -success() + ->from( + config('backup.notifications.discord.username'), + config('backup.notifications.discord.avatar_url') + ) + ->title( + __('The backups for :application_name are healthy', [ + 'application_name' => $this->applicationName(), + ]) + ); + + $this->getStatuses()->each(function (BackupDestinationStatus $status) use ($message) { + $message->fields($this->backupDestinationProperties($status->backupDestination())->toArray()); + }); + + return $message; + } } diff --git a/src/Notifications/Messages/DiscordMessage.php b/src/Notifications/Messages/DiscordMessage.php new file mode 100644 index 0000000..2007f68 --- /dev/null +++ b/src/Notifications/Messages/DiscordMessage.php @@ -0,0 +1,238 @@ + + */ +class DiscordMessage +{ + /* ----------------------------------------------------------------- + | Constants + | ----------------------------------------------------------------- + */ + + public const COLOR_SUCCESS = '0b6623'; + public const COLOR_WARNING = 'fD6a02'; + public const COLOR_ERROR = 'e32929'; + + /* ----------------------------------------------------------------- + | Properties + | ----------------------------------------------------------------- + */ + + /** + * @var string + */ + protected $username = 'Laravel Backup'; + + /** + * @var string|null + */ + protected $avatarUrl = null; + + /** + * @var string + */ + protected $title = ''; + + /** + * @var string + */ + protected $description = ''; + + /** + * @var array + */ + protected $fields = []; + + /** + * @var string|null + */ + protected $timestamp = null; + + /** + * @var string|null + */ + protected $footer = null; + + /** + * @var string|null + */ + protected $color = null; + + /** + * @var string + */ + protected $url = ''; + + /* ----------------------------------------------------------------- + | Getters & Setters + | ----------------------------------------------------------------- + */ + + /** + * @param string $username + * @param string|null $avatarUrl + * + * @return $this + */ + public function from(string $username, string $avatarUrl = null): self + { + if ( ! is_null($username)) + $this->username = $username; + + if ( ! is_null($avatarUrl)) + $this->avatarUrl = $avatarUrl; + + return $this; + } + + /** + * @param string $url + * + * @return $this + */ + public function url(string $url): self + { + $this->url = $url; + + return $this; + } + + /** + * @param string $title + * + * @return $this + */ + public function title(string $title): self + { + $this->title = $title; + + return $this; + } + + /** + * @param string $description + * + * @return $this + */ + public function description(string $description): self + { + $this->description = $description; + + return $this; + } + + /** + * @param \Illuminate\Support\Carbon $carbon + * + * @return $this + */ + public function timestamp(Carbon $carbon): self + { + $this->timestamp = $carbon->toIso8601String(); + + return $this; + } + + /** + * @param string $footer + * + * @return $this + */ + public function footer(string $footer): self + { + $this->footer = $footer; + + return $this; + } + + /** + * Set the success color. + * + * @return $this + */ + public function success(): self + { + $this->color = static::COLOR_SUCCESS; + + return $this; + } + + /** + * Set the warning color. + * + * @return $this + */ + public function warning(): self + { + $this->color = static::COLOR_WARNING; + + return $this; + } + + /** + * Set the error color. + * + * @return $this + */ + public function error(): self + { + $this->color = static::COLOR_ERROR; + + return $this; + } + + /** + * Set the fields. + * + * @param array $fields + * @param bool $inline + * + * @return $this + */ + public function fields(array $fields, bool $inline = true): self + { + foreach ($fields as $label => $value) { + $this->fields[] = [ + 'name' => $label, + 'value' => $value, + 'inline' => $inline, + ]; + } + + return $this; + } + + /** + * Convert the message instance into an array. + * + * @return array + */ + public function toArray(): array + { + return [ + 'username' => 'Laravel Backup', + 'avatar_url' => '', + 'embeds' => [ + [ + 'title' => $this->title, + 'url' => $this->url, + 'type' => 'rich', + 'description' => $this->description, + 'fields' => $this->fields, + 'color' => hexdec($this->color), + 'footer' => [ + 'text' => $this->footer ?? '', + ], + 'timestamp' => $this->timestamp ?? Carbon::now(), + ], + ], + ]; + } +} diff --git a/src/Notifications/UnhealthyBackupsWasFoundNotification.php b/src/Notifications/UnhealthyBackupsWasFoundNotification.php index 4205659..ea6b2b5 100644 --- a/src/Notifications/UnhealthyBackupsWasFoundNotification.php +++ b/src/Notifications/UnhealthyBackupsWasFoundNotification.php @@ -1,10 +1,9 @@ -error() + ->from( + config('backup.notifications.discord.username'), + config('backup.notifications.discord.avatar_url') + ) + ->title( + __('Important: The backups for :application_name are unhealthy', [ + 'application_name' => $applicationName = $this->applicationName(), + ]) + ); + + $this->getStatuses()->each(function (BackupDestinationStatus $status) use ($message) { + $message->fields( + $this->backupDestinationProperties($status->backupDestination())->toArray() + ); + + $failure = $status->getHealthCheckFailure(); + + if ($failure->wasUnexpected()) { + $message + ->fields(['Health Check' => $failure->healthCheck()->name()]) + ->fields([ + trans('backup::notifications.exception_message_title') => $failure->exception()->getMessage(), + ]); + } + }); + + return $message; + } + /* ----------------------------------------------------------------- | Other Methods | ----------------------------------------------------------------- diff --git a/src/Providers/DeferredServiceProvider.php b/src/Providers/DeferredServiceProvider.php index 31b37e0..f3244c8 100644 --- a/src/Providers/DeferredServiceProvider.php +++ b/src/Providers/DeferredServiceProvider.php @@ -1,6 +1,4 @@ - + */ +class NotificationServiceProvider extends ServiceProvider +{ + /* ----------------------------------------------------------------- + | Main Methods + | ----------------------------------------------------------------- + */ + + /** + * Register any application services. + */ + public function register(): void + { + $this->registerDiscordChannel(); + } + + /* ----------------------------------------------------------------- + | Other Methods + | ----------------------------------------------------------------- + */ + + /** + * Register the discord channel. + */ + protected function registerDiscordChannel(): void + { + Notification::resolved(function (ChannelManager $service): void { + $service->extend('discord', function ($app) { + return new DiscordChannel; + }); + }); + } +} diff --git a/tests/Asserts/AssertZipFile.php b/tests/Asserts/AssertZipFile.php index be884cc..6038cc9 100644 --- a/tests/Asserts/AssertZipFile.php +++ b/tests/Asserts/AssertZipFile.php @@ -1,6 +1,4 @@ - + */ +class ListBackupCommandTest extends TestCase +{ + /* ----------------------------------------------------------------- + | Tests + | ----------------------------------------------------------------- + */ + + /** @test */ + public function it_can_run(): void + { + $this->artisan('backup:list') + ->assertExitCode(Command::SUCCESS); + } +} diff --git a/tests/Console/MonitorBackupCommandTest.php b/tests/Console/MonitorBackupCommandTest.php index 963c289..0aa316c 100644 --- a/tests/Console/MonitorBackupCommandTest.php +++ b/tests/Console/MonitorBackupCommandTest.php @@ -1,6 +1,4 @@ -app['config']->set('backup.backup.db-dump-compressor', GzipCompressor::class); + $this->app['config']->set('backup.backup.db-dump.compressor', GzipCompressor::class); $this->artisan('backup:run --only-db') ->expectsOutput('Starting backup...') @@ -112,12 +113,49 @@ public function it_can_run_backup_with_compressor(): void ]); Event::assertDispatched(BackupManifestWasCreated::class); + Event::assertDispatched(DumpingDatabase::class); Event::assertDispatched(BackupZipWasCreated::class); Event::assertDispatched(BackupActionWasSuccessful::class); Event::assertNotDispatched(BackupActionHasFailed::class); } + /** @test */ + public function it_can_backup_using_relative_path(): void + { + $this->app['config']->set('backup.backup.source.files.include', [$this->getDiskRootPath('primary-storage')]); + $this->app['config']->set('backup.backup.source.files.relative-path', $this->getDiskRootPath('primary-storage')); + + Storage::disk('primary-storage')->put('testing-file.txt', 'dummy content'); + + $this->artisan('backup:run --only-files') + ->assertExitCode(0); + + static::assertBackupFilesExistsInZipFile([ + 'files\testing-file.txt', + ], ['primary-storage']); + } + + /** @test */ + public function it_renames_database_dump_file_extension_when_specified(): void + { + $this->app['config']->set('backup.backup.db-dump.file-extension', 'backup'); + + $this->artisan('backup:run --only-db') + ->assertExitCode(0); + + static::assertBackupFilesExistsInZipFile([ + 'databases/dump-sqlite-db-1.backup', + 'databases/dump-sqlite-db-2.backup', + ]); + + /* + * Close the database connection to unlock the sqlite file for deletion. + * This prevents the errors from other tests trying to delete and recreate the folder. + */ + $this->app['db']->disconnect(); + } + /* ----------------------------------------------------------------- | Asserts | ----------------------------------------------------------------- diff --git a/tests/Database/DbDumperManagerTest.php b/tests/Database/DbDumperManagerTest.php index cd21904..9450b36 100644 --- a/tests/Database/DbDumperManagerTest.php +++ b/tests/Database/DbDumperManagerTest.php @@ -1,6 +1,4 @@ -dumper + ->setDbName('dbname') + ->useCompressor(new Bzip2Compressor) + ->getDumpCommand('dbname.bz2'); + + $expected = '((((\'mongodump\' --db dbname --archive --host localhost --port 27017; echo $? >&3) | bzip2 > "dbname.bz2") 3>&1) | (read x; exit $x))'; + + static::assertSameCommand($expected, $actual); + } + /** @test */ public function it_can_generate_a_dump_command_with_absolute_path_having_space_and_brackets(): void { diff --git a/tests/Database/Dumpers/MySqlDumperTest.php b/tests/Database/Dumpers/MySqlDumperTest.php index d26ecc5..798052f 100644 --- a/tests/Database/Dumpers/MySqlDumperTest.php +++ b/tests/Database/Dumpers/MySqlDumperTest.php @@ -1,6 +1,4 @@ -dumper + $actual = $this->dumper ->setDbName('dbname') ->setUserName('username') ->setPassword('password') @@ -80,7 +78,7 @@ public function it_can_generate_a_dump_command_with_column_statistics(): void $expected = '\'mysqldump\' --defaults-extra-file="credentials.txt" --skip-comments --extended-insert --column-statistics=0 dbname > "dump.sql"'; - static::assertSameCommand($expected, $dumpCommand); + static::assertSameCommand($expected, $actual); } /** @test */ @@ -128,7 +126,7 @@ public function it_can_generate_a_dump_command_without_using_comments(): void } /** @test */ - public function it_can_generate_a_dump_command_without_using_extended_insterts(): void + public function it_can_generate_a_dump_command_without_using_extended_inserts(): void { $actual = $this->dumper ->setDbName('dbname') @@ -329,11 +327,26 @@ public function it_can_generate_the_contents_of_a_credentials_file(): void ->setSocket(1234) ->getDbCredentials(); - $expected = '[client]'.PHP_EOL."user = 'username'".PHP_EOL."password = 'password'".PHP_EOL."host = 'hostname'".PHP_EOL."port = '3306'"; + $expected = '[client]'.PHP_EOL."user = 'username'".PHP_EOL."password = 'password'".PHP_EOL."port = '3306'"; static::assertSame($expected, $credentialsFileContent); } + /** @test */ + public function it_can_generate_the_contents_of_a_credentials_file_with_a_http_connection() + { + $actual = $this->dumper + ->setDbName('dbname') + ->setUserName('username') + ->setPassword('password') + ->setHost('hostname') + ->getDbCredentials(); + + $expected = '[client]'.PHP_EOL."user = 'username'".PHP_EOL."password = 'password'".PHP_EOL."port = '3306'".PHP_EOL."host = 'hostname'"; + + static::assertSame($expected, $actual); + } + /** @test */ public function it_can_get_the_name_of_the_db(): void { @@ -358,6 +371,22 @@ public function it_can_add_extra_options(): void static::assertSameCommand($expected, $actual); } + /** @test */ + public function it_can_add_extra_options_after_db_name(): void + { + $actual = $this->dumper + ->setDbName('dbname') + ->setUserName('username') + ->setPassword('password') + ->addExtraOption('--extra-option') + ->addExtraOptionAfterDbName('--another-extra-option="value"') + ->getDumpCommand('dump.sql', 'credentials.txt'); + + $expected = '\'mysqldump\' --defaults-extra-file="credentials.txt" --skip-comments --extended-insert --extra-option dbname --another-extra-option="value" > "dump.sql"'; + + static::assertSameCommand($expected, $actual); + } + /** @test */ public function it_can_get_the_host(): void { diff --git a/tests/Database/Dumpers/PostgreSqlDumperTest.php b/tests/Database/Dumpers/PostgreSqlDumperTest.php index beeaaf5..99e949a 100644 --- a/tests/Database/Dumpers/PostgreSqlDumperTest.php +++ b/tests/Database/Dumpers/PostgreSqlDumperTest.php @@ -1,6 +1,4 @@ -dumper = new SqliteDumper; } - protected function tearDown(): void - { - self::deleteTempDirectory(); - - parent::tearDown(); - } - /* ----------------------------------------------------------------- | Tests | ----------------------------------------------------------------- @@ -81,6 +72,20 @@ public function it_can_generate_a_dump_command_with_gzip_compressor_enabled(): v static::assertSameCommand($expected, $actual); } + /** @test */ + public function it_can_generate_a_dump_command_with_bzip2_compressor_enabled(): void + { + $actual = $this->dumper + ->setDbName('database.sqlite') + ->useCompressor(new Bzip2Compressor) + ->getDumpCommand('dump.sql'); + + $expected = '((((echo \'BEGIN IMMEDIATE; +.dump\' | \'sqlite3\' --bail \'database.sqlite\'; echo $? >&3) | bzip2 > "dump.sql") 3>&1) | (read x; exit $x))'; + + static::assertSameCommand($expected, $actual); + } + /** @test */ public function it_can_generate_a_dump_command_with_absolute_paths(): void { @@ -110,8 +115,8 @@ public function it_can_generate_a_dump_command_with_absolute_paths_having_space_ /** @test */ public function it_successfully_creates_a_backup(): void { - $dbPath = static::getTempDirectory('databases/database.sqlite'); - $dbBackupPath = static::getTempDirectory('databases/backup.sql'); + $dbPath = static::tempDirectory('databases/database.sqlite'); + $dbBackupPath = static::tempDirectory('databases/backup.sql'); $this->dumper ->setDbName($dbPath) diff --git a/tests/Entities/BackupDestinationCollectionTest.php b/tests/Entities/BackupDestinationCollectionTest.php index e0fc748..7ae8c5a 100644 --- a/tests/Entities/BackupDestinationCollectionTest.php +++ b/tests/Entities/BackupDestinationCollectionTest.php @@ -1,6 +1,4 @@ -write($this->getStubsDirectory($path = 'files/.dotfile')); + $destination->write($this->stubsDirectory($path = 'files/.dotfile')); Storage::disk($this->diskName)->assertExists('ARCANEDEV/.dotfile'); } @@ -139,10 +137,10 @@ public function it_can_write_write_file_into_destination(): void public function it_cannot_write_file_on_invalid_disk(): void { $this->expectException(InvalidBackupDestination::class); - $this->expectExceptionMessage('There is no disk set for the backup named `ARCANEDEV`'); + $this->expectExceptionMessage('There is a connection error when trying to connect to disk named `not-found`'); BackupDestination::makeFromDiskName('not-found', $this->backupName) - ->write($this->getStubsDirectory('files/.dotfile')); + ->write($this->stubsDirectory('files/.dotfile')); } /** @test */ diff --git a/tests/Entities/BackupTest.php b/tests/Entities/BackupTest.php index 30ed43f..c7630d5 100644 --- a/tests/Entities/BackupTest.php +++ b/tests/Entities/BackupTest.php @@ -1,6 +1,4 @@ -manifest = new Manifest( - $this->path = static::getTempDirectory() + $this->path = static::tempDirectory() ); } - protected function tearDown(): void - { - static::deleteTempDirectory(); - - parent::tearDown(); - } - /* ----------------------------------------------------------------- | Tests | ----------------------------------------------------------------- diff --git a/tests/EventSubscribers/BackupEventSubscriberTest.php b/tests/EventSubscribers/BackupEventSubscriberTest.php index 11997a4..e9a77bf 100644 --- a/tests/EventSubscribers/BackupEventSubscriberTest.php +++ b/tests/EventSubscribers/BackupEventSubscriberTest.php @@ -1,6 +1,4 @@ -filesSelector = $this->app->make(FilesSelector::class); - $this->sourceDirectory = static::getStubsDirectory('files'); + $this->sourceDirectory = static::stubsDirectory('files'); $this->filesSelector->reset(); } @@ -87,6 +85,7 @@ public function it_can_select_all_the_files_in_a_directory_and_subdirectories(): $expected = $this->getTestFiles([ '.dotfile', '1Mb.file', + 'archive.zip', 'directory-1', 'directory-1/sub-directory-1', 'directory-1/sub-directory-1/file-1.txt', @@ -115,6 +114,7 @@ public function it_can_exclude_files_from_a_given_subdirectory(): void $expected = $this->getTestFiles([ '.dotfile', '1Mb.file', + 'archive.zip', 'directory-2', 'directory-2/sub-directory-1', 'directory-2/sub-directory-1/file-1.txt', @@ -137,6 +137,7 @@ public function it_can_exclude_files_with_wildcards_from_a_given_subdirectory(): $expected = $this->getTestFiles([ '.dotfile', '1Mb.file', + 'archive.zip', 'directory-1', 'directory-1/file-1.txt', 'directory-1/file-2.txt', @@ -182,6 +183,7 @@ public function it_can_exclude_files_from_multiple_directories(): void $expected = $this->getTestFiles([ '.dotfile', '1Mb.file', + 'archive.zip', 'directory-1', 'directory-1/file-1.txt', 'directory-1/file-2.txt', diff --git a/tests/Helpers/FormatTest.php b/tests/Helpers/FormatTest.php index 0100778..5093e25 100644 --- a/tests/Helpers/FormatTest.php +++ b/tests/Helpers/FormatTest.php @@ -1,6 +1,4 @@ -filesPath = static::getTempDirectory('zip/files') + $this->filesPath = static::tempDirectory('zip/files') ); $this->files = static::getAllFiles($this->filesPath, true); $this->zip = new Zip( - $this->zipPath = static::getTempDirectory('zip/file.zip') + $this->zipPath = static::tempDirectory('zip/file.zip') ); } - protected function tearDown(): void - { - static::deleteTempDirectory(); - - parent::tearDown(); - } - /* ----------------------------------------------------------------- | Tests | ----------------------------------------------------------------- @@ -87,7 +79,7 @@ public function it_can_create_zip(): void static::assertSame(count($this->files), $this->zip->count()); static::assertGreaterThan(1500, $this->zip->size()); - $files = array_map(function (\SplFileInfo $file) { + $files = array_map(function (SplFileInfo $file) { return $this->zip->guessFilenameInArchive($file->getPathname()); }, $this->files); diff --git a/tests/Listeners/EncryptBackupArchiveTest.php b/tests/Listeners/EncryptBackupArchiveTest.php new file mode 100644 index 0000000..2e02544 --- /dev/null +++ b/tests/Listeners/EncryptBackupArchiveTest.php @@ -0,0 +1,159 @@ + + */ +class EncryptBackupArchiveTest extends TestCase +{ + /* ----------------------------------------------------------------- + | Properties + | ----------------------------------------------------------------- + */ + + protected const PASSWORD = '24dsjF6BPjWgUfTu'; + + /* ----------------------------------------------------------------- + | Main Methods + | ----------------------------------------------------------------- + */ + + protected function setUp(): void + { + parent::setUp(); + + static::initTempDirectory(); + + copy(static::stubsDirectory('files/archive.zip'), static::tempDirectory('archive.zip')); + + $this->app['config']->set('backup.backup.password', self::PASSWORD); + } + + /* ----------------------------------------------------------------- + | Tests + | ----------------------------------------------------------------- + */ + + /** @test */ + public function it_keeps_archive_unencrypted_without_password(): void + { + $this->app['config']->set('backup.backup.password', null); + + $zip = $this->zip()->open(); + + static::assertEncryptionMethod($zip, ZipArchive::EM_NONE); + + static::assertTrue( + $zip->extractTo(static::tempDirectory('extraction')) + ); + static::assertValidExtractedFiles(); + + $zip->close(); + } + + /** + * @test + * + * @dataProvider encryptionMethodsDataProvider + * + * @param int $algorithm + */ + public function it_encrypts_archive_with_password(int $algorithm): void + { + $this->app['config']->set('backup.backup.encryption', $algorithm); + + $zip = $this->zip()->open(); + + static::assertEncryptionMethod($zip, $algorithm); + + $zip->setPassword(self::PASSWORD); + + static::assertTrue( + $zip->extractTo(self::tempDirectory('extraction')) + ); + static::assertValidExtractedFiles(); + + $zip->close(); + } + + /** @test */ + public function it_can_not_open_encrypted_archive_without_password(): void + { + $zip = $this->zip()->open(); + + static::assertEncryptionMethod($zip, ZipArchive::EM_AES_256); + + static::assertFalse( + $zip->extractTo(self::tempDirectory('extraction')) + ); + + $zip->close(); + } + + /* ----------------------------------------------------------------- + | Other Methods + | ----------------------------------------------------------------- + */ + + /** + * Prepare the zip archive. + * + * @return \Arcanedev\LaravelBackup\Helpers\Zip + */ + protected function zip(): Zip + { + return tap(new Zip(static::tempDirectory('archive.zip')), function (Zip $zip): void { + $this->app->call(EncryptBackupArchive::class.'@handle', [ + 'event' => new BackupZipWasCreated($zip) + ]); + }); + } + + /* ----------------------------------------------------------------- + | Asserts + | ----------------------------------------------------------------- + */ + + /** + * @param \Arcanedev\LaravelBackup\Helpers\Zip $zip + * @param int $algorithm + */ + protected static function assertEncryptionMethod(Zip $zip, int $algorithm): void + { + foreach (range(0, $zip->numFiles - 1) as $i) { + static::assertSame($algorithm, $zip->statIndex($i)['encryption_method']); + } + } + + protected static function assertValidExtractedFiles(): void + { + foreach (['file1.txt', 'file2.txt', 'file3.txt'] as $filename) { + $filepath = static::tempDirectory('extraction/'.$filename); + static::assertTrue(file_exists($filepath)); + static::assertSame('lorum ipsum', file_get_contents($filepath)); + } + } + + /* ----------------------------------------------------------------- + | Data Providers + | ----------------------------------------------------------------- + */ + + public function encryptionMethodsDataProvider(): array + { + return [ + [ZipArchive::EM_AES_128], + [ZipArchive::EM_AES_192], + [ZipArchive::EM_AES_256], + ]; + } +} diff --git a/tests/Providers/DeferredServiceProviderTest.php b/tests/Providers/DeferredServiceProviderTest.php index 37a43ff..a2b261a 100644 --- a/tests/Providers/DeferredServiceProviderTest.php +++ b/tests/Providers/DeferredServiceProviderTest.php @@ -1,6 +1,4 @@ -set('database.connections.sqlite-db-1', [ 'driver' => 'sqlite', - 'database' => static::getTempDirectory('databases/database-1.sqlite'), + 'database' => static::tempDirectory('databases/database-1.sqlite'), ]); $config->set('database.connections.sqlite-db-2', [ 'driver' => 'sqlite', - 'database' => static::getTempDirectory('databases/database-2.sqlite'), + 'database' => static::tempDirectory('databases/database-2.sqlite'), ]); diff --git a/tests/_stubs/files/archive.zip b/tests/_stubs/files/archive.zip new file mode 100644 index 0000000000000000000000000000000000000000..f63e7ec52095800845bfac8939d8f74806a3d3db GIT binary patch literal 583 zcmWIWW@Zs#-~d9?j_@D`DBuRtoD2#KX_+~xhI%CxC7~g_4D8R67|eXG2fuXy~0~WeD(Q=UD2R!^aDh0GSj(&<-OMJ6;0q0og@eJB(56 zctMsOj7%cTh|ogzBPg_BU`Zp0MReE&coS=E3e;Glf{S=#Pe6?&Due>OS=m6|V+KN2 LVD#$%F#`htu~lw# literal 0 HcmV?d00001