diff --git a/src/Illuminate/Foundation/Console/CryptRefreshCommand.php b/src/Illuminate/Foundation/Console/CryptRefreshCommand.php new file mode 100644 index 000000000000..e5e7a9aa656d --- /dev/null +++ b/src/Illuminate/Foundation/Console/CryptRefreshCommand.php @@ -0,0 +1,187 @@ +confirmToProceed(); + + [$table, $columns, $id, $flagColumn] = $this->parseOptions(); + + $query = $this->createQueryFor($table); + + $this->ensureFlagColumnShouldExist($schema, $table, $flagColumn); + + $this->withProgressBar( + $this->getRowsLazily($query, $columns, $id, $flagColumn), + function ($row) use ($date, $encrypter, $id, $columns, $query, $flagColumn) { + $data = []; + + foreach ($columns as $column) { + if (is_string($row->{$column}) && $row->{$column}) { + $data[$column] = $encrypter->encrypt($encrypter->decrypt($row->{$column}, false), false); + } + } + + if ($flagColumn) { + $data[$flagColumn] = $date->now(); + } + + $query->clone()->where($id, $row->{$id})->update($data); + } + ); + + $this->removeFlagColumnIfExists($schema, $table, $flagColumn); + } + + /** + * Return the flag column name. + * + * @return string|false + */ + protected function flagColumn() + { + $name = (string) $this->option('flag-column'); + + if (in_array(strtolower($name), ['none', 'null', 'false', '0', ''], true)) { + return false; + } + + return $name; + } + + /** + * Return a configuration value from the app by its key. + * + * @param string $key + * @param mixed $default + * @return mixed + */ + protected function config($key, $default = null) + { + return $this->laravel->make('config')->get($key, $default); + } + + /** + * Parse the options of the command. + * + * @return array{table: string, columns: string[], id: string, flagColumn: string|false } + */ + protected function parseOptions() + { + [$table, $columns] = array_pad(explode(':', $this->argument('targets'), 2), 2, null); + + if (! $table) { + throw new InvalidArgumentException('No table name was issued.'); + } + + $columns = array_filter(array_map('trim', explode(',', $columns))); + + if (! $columns) { + throw new InvalidArgumentException('No columns were issued.'); + } + + return [$table, $columns, $this->option('id'), $this->flagColumn()]; + } + + /** + * Create a lazy query for the target model. + * + * @param \Illuminate\Database\Query\Builder $query + * @param string[] $columns + * @param string $id + * @param string|false $flagColumn + * @return \Illuminate\Support\LazyCollection + */ + protected function getRowsLazily($query, $columns, $id, $flagColumn) + { + return $query + ->clone() + ->select([$id, ...$columns]) + ->when($flagColumn)->whereNull($flagColumn) + ->lazyById($this->option('chunk'), $id); + } + + /** + * Create a new Query Builder instance for the given table. + * + * @param string $table + */ + protected function createQueryFor($table) + { + return $this->laravel->make('db')->connection($this->option('connection'))->table($table); + } + + /** + * Ensure the target table has a column to skip successfully refreshed rows. + * + * @param \Illuminate\Database\Schema\Builder $schema + * @param string $name + * @param string $column + * @return void + */ + protected function ensureFlagColumnShouldExist($schema, $name, $column) + { + if (! $column) { + $this->warn("No flag column was issued to skip already refreshed rows."); + + return; + } + + $this->info("Using $column as flag column in $name to refresh rows."); + + $schema->whenTableDoesntHaveColumn($name, $column, fn ($table) => $table->timestamp($column)->nullable()); + } + + /** + * Removes the Flag Column if it exists on the target table. + * + * @param \Illuminate\Database\Schema\Builder $schema + * @param string $name + * @param string $column + * @return void + */ + protected function removeFlagColumnIfExists(SchemaContract $schema, mixed $name, mixed $column) + { + $schema->whenTableHasColumn($name, $column, fn (Blueprint $builder) => $builder->dropColumn($column)); + } +} diff --git a/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php b/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php index 4ec8289653d7..5fbaa9d26001 100755 --- a/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php +++ b/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php @@ -67,6 +67,7 @@ use Illuminate\Foundation\Console\PackageDiscoverCommand; use Illuminate\Foundation\Console\PolicyMakeCommand; use Illuminate\Foundation\Console\ProviderMakeCommand; +use Illuminate\Foundation\Console\CryptRefreshCommand; use Illuminate\Foundation\Console\RequestMakeCommand; use Illuminate\Foundation\Console\ResourceMakeCommand; use Illuminate\Foundation\Console\RouteCacheCommand; @@ -189,6 +190,7 @@ class ArtisanServiceProvider extends ServiceProvider implements DeferrableProvid 'ConfigPublish' => ConfigPublishCommand::class, 'ConsoleMake' => ConsoleMakeCommand::class, 'ControllerMake' => ControllerMakeCommand::class, + 'CryptRefresh' => CryptRefreshCommand::class, 'Docs' => DocsCommand::class, 'EnumMake' => EnumMakeCommand::class, 'EventGenerate' => EventGenerateCommand::class, diff --git a/tests/Integration/Console/CryptRefreshCommandTest.php b/tests/Integration/Console/CryptRefreshCommandTest.php new file mode 100644 index 000000000000..0f61c072b838 --- /dev/null +++ b/tests/Integration/Console/CryptRefreshCommandTest.php @@ -0,0 +1,184 @@ +appKey; + $app['config']['app.previous_keys'] = ['base64:' . $this->oldKey]; + $app['config']['app.cipher'] = $this->cipher; + } + + protected function setUp(): void + { + $this->afterApplicationCreated(function () { + $this->app->make('db.schema')->create('test_models', function (Blueprint $table) { + $table->id(); + $table->text('foo'); + $table->text('bar'); + $table->text('baz')->nullable(); + }); + + $this->app->make('db')->table('test_models')->insert([ + 'foo' => 'eyJpdiI6ImZJQnhqVGc5QmRDL1VIOHU3R1Z0S2c9PSIsInZhbHVlIjoiNDYvRk9CWW1hOGFDaGZyMklaOWxidz09IiwibWFjIjoiZjcwMTE0MDVmY2E4MmQ4YjU3MDYwZWIzYTdhZjJlNWE5YjM2YTZjODRkOWM2MmVmZDNmNDlkZDAzMWViNmMwOCIsInRhZyI6IiJ9', + 'bar' => 'bar', + ]); + }); + + parent::setUp(); + } + + public function testRefreshesEncryption() + { + $this->artisan('crypt:refresh', ['targets' => 'test_models:foo,baz',]) + ->expectsOutput('Using laravel_refreshed_at as flag column in test_models to refresh rows.') + ->assertExitCode(0); + + $row = $this->app->make('db')->table('test_models')->where('id', 1)->first(); + + $encrypter = $this->app->make('encrypter'); + + $this->assertSame('foo', $encrypter->decrypt($row->foo, false)); + $this->assertSame('bar', $row->bar); + $this->assertNull($row->baz); + $this->assertObjectNotHasProperty('refreshed_at', $row); + + $this->assertFalse($this->app->make('db.schema')->hasColumn('test_models', 'laravel_refreshed_at')); + } + + public function testRefreshesEncryptionWithCustomFlagColumn() + { + $this->artisan('crypt:refresh', [ + 'targets' => 'test_models:foo,baz', + '--flag-column' => 'custom_refreshed_at' + ]) + ->expectsOutput('Using custom_refreshed_at as flag column in test_models to refresh rows.') + ->assertExitCode(0); + + $row = $this->app->make('db')->table('test_models')->where('id', 1)->first(); + + $encrypter = $this->app->make('encrypter'); + + $this->assertSame('foo', $encrypter->decrypt($row->foo, false)); + $this->assertSame('bar', $row->bar); + $this->assertNull($row->baz); + $this->assertObjectNotHasProperty('custom_refreshed_at', $row); + + $this->assertFalse($this->app->make('db.schema')->hasColumn('test_models', 'custom_refreshed_at')); + $this->assertFalse($this->app->make('db.schema')->hasColumn('test_models', 'laravel_refreshed_at')); + } + + public function testDoesntRefreshesAlreadyRefreshedRow() + { + $this->app->make('db.schema')->table('test_models', fn($table) => $table->timestamp('laravel_refreshed_at')->nullable()); + $this->app->make('db')->table('test_models') + ->where('id', 1) + ->update([ + 'foo' => 'untouched', + 'bar' => 'untouched', + 'baz' => null, + 'laravel_refreshed_at' => now(), + ]); + + $this->artisan('crypt:refresh', ['targets' => 'test_models:foo,baz'])->assertExitCode(0); + + $row = $this->app->make('db')->table('test_models')->where('id', 1)->first(); + + $this->assertSame('untouched', $row->foo); + $this->assertSame('untouched', $row->bar); + $this->assertNull($row->baz); + } + + public function testRefreshesAllColumnsWhenFlagColumnDisabled() + { + $this->app->make('db.schema')->table('test_models', fn($table) => $table->timestamp('laravel_refreshed_at')->nullable()); + $this->app->make('db')->table('test_models') + ->where('id', 1) + ->update([ + 'laravel_refreshed_at' => now(), + ]); + + $this->artisan('crypt:refresh', [ + 'targets' => 'test_models:foo,baz', + '--flag-column' => null + ]) + ->expectsOutput('No flag column was issued to skip already refreshed rows.') + ->assertExitCode(0); + + $row = $this->app->make('db')->table('test_models')->where('id', 1)->first(); + + $this->assertNotSame('untouched', $row->foo); + $this->assertNotSame('untouched', $row->bar); + $this->assertNull($row->baz); + } + + public function testDoesntRefreshesAlreadyRefreshedRowWithCustomFlagColumn() + { + $this->app->make('db.schema')->table('test_models', fn($table) => $table->timestamp('custom_at')->nullable()); + $this->app->make('db')->table('test_models') + ->where('id', 1) + ->update([ + 'foo' => 'untouched', + 'bar' => 'untouched', + 'baz' => null, + 'custom_at' => now(), + ]); + + $this->artisan('crypt:refresh', [ + 'targets' => 'test_models:foo,baz', + '--flag-column' => 'custom_at' + ])->assertExitCode(0); + + $row = $this->app->make('db')->table('test_models')->where('id', 1)->first(); + + $this->assertSame('untouched', $row->foo); + $this->assertSame('untouched', $row->bar); + $this->assertNull($row->baz); + $this->assertObjectNotHasProperty('custom_at', $row); + + $this->assertFalse($this->app->make('db.schema')->hasColumn('test_models', 'custom_at')); + $this->assertFalse($this->app->make('db.schema')->hasColumn('test_models', 'laravel_refreshed_at')); + } + + public function testFailsWhenEmptyTable() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('No table name was issued.'); + + $this->artisan('crypt:refresh', [ + 'targets' => '', + ]); + } + + public function testFailsWhenEmptyColumns() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('No columns were issued.'); + + $this->artisan('crypt:refresh', [ + 'targets' => 'table: ', + ]); + } + + public function testFailsWhenColumnIsNotEncrypted() + { + $this->expectException(DecryptException::class); + $this->expectExceptionMessage('The payload is invalid.'); + + $this->artisan('crypt:refresh', [ + 'targets' => 'test_models:bar', + ]); + } +}