diff --git a/ProcessMaker/Events/SecurityLogDownloadFailed.php b/ProcessMaker/Events/SecurityLogDownloadFailed.php new file mode 100644 index 0000000000..69a4611e6c --- /dev/null +++ b/ProcessMaker/Events/SecurityLogDownloadFailed.php @@ -0,0 +1,75 @@ +user = $user; + $this->success = $success; + $this->message = $message; + $this->link = $link; + } + + /** + * Get the channels the event should broadcast on. + * + * @return \Illuminate\Broadcasting\Channel|array + */ + public function broadcastOn() + { + return new PrivateChannel("ProcessMaker.Models.User.{$this->user->id}"); + } + + /** + * Set the event name + * + * @return string + */ + public function broadcastAs() + { + return 'SecurityLogDownloadFailed'; + } + + /** + * Set the data to broadcast with this event + * + * @return array + */ + public function broadcastWith() + { + return [ + 'success' => $this->success, + 'message' => $this->message, + 'link' => $this->link, + ]; + } +} diff --git a/ProcessMaker/Events/SecurityLogDownloadJobCompleted.php b/ProcessMaker/Events/SecurityLogDownloadJobCompleted.php index e8c3a2801b..a4007ff5e0 100644 --- a/ProcessMaker/Events/SecurityLogDownloadJobCompleted.php +++ b/ProcessMaker/Events/SecurityLogDownloadJobCompleted.php @@ -11,7 +11,9 @@ class SecurityLogDownloadJobCompleted implements ShouldBroadcastNow { - use Dispatchable, InteractsWithSockets, SerializesModels; + use Dispatchable; + use InteractsWithSockets; + use SerializesModels; public $user; diff --git a/ProcessMaker/Http/Controllers/Api/SecurityLogController.php b/ProcessMaker/Http/Controllers/Api/SecurityLogController.php index 08843563a9..918e476763 100644 --- a/ProcessMaker/Http/Controllers/Api/SecurityLogController.php +++ b/ProcessMaker/Http/Controllers/Api/SecurityLogController.php @@ -11,6 +11,7 @@ use ProcessMaker\Http\Resources\ApiResource; use ProcessMaker\Http\Resources\SecurityLogs; use ProcessMaker\Jobs\DownloadSecurityLog; +use ProcessMaker\Models\Media; use ProcessMaker\Models\SecurityLog; use ProcessMaker\Models\User; @@ -129,7 +130,7 @@ public function store(Request $request) { $request->validate(SecurityLog::rules()); - $securityLog = new SecurityLog; + $securityLog = new SecurityLog(); $fields = SensitiveDataHelper::parseArray($request->json()->all()); $securityLog->fill($fields); $securityLog->saveOrFail(); @@ -139,17 +140,25 @@ public function store(Request $request) private function download(Request $request, User $user = null) { + if (!Media::s3IsReady()) { + return response()->json([ + 'success' => false, + 'message' => __('Sorry, this feature requires the configured AWS S3 service. Please contact the administrator.') + ]); + } $request->validate([ 'format' => 'required|string|in:xml,csv', ]); - sleep(1); $sessionUser = Auth::user(); + + // Call the Event DownloadSecurityLog::dispatch($sessionUser, $request->input('format'), $user ? $user->id : null) ->delay(now()->addSeconds(5)); return response()->json([ - 'message' => __('The log file is being prepared and will be sent to your email as soon as it is ready.'), - ], 200); + 'success' => true, + 'message' => __('The file is processing... Please wait for an alert with the download link.') + ]); } public function downloadForAllUsers(Request $request) diff --git a/ProcessMaker/Jobs/DownloadSecurityLog.php b/ProcessMaker/Jobs/DownloadSecurityLog.php index 4403455969..9d9a56ea9c 100644 --- a/ProcessMaker/Jobs/DownloadSecurityLog.php +++ b/ProcessMaker/Jobs/DownloadSecurityLog.php @@ -2,20 +2,29 @@ namespace ProcessMaker\Jobs; +use Exception; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; +use ProcessMaker\Events\SecurityLogDownloadFailed; use ProcessMaker\Events\SecurityLogDownloadJobCompleted; +use ProcessMaker\Models\Media; +use ProcessMaker\Models\SecurityLog; use ProcessMaker\Models\User; +use Ramsey\Uuid\Uuid; class DownloadSecurityLog implements ShouldQueue { - use Dispatchable, - InteractsWithQueue, - Queueable, - SerializesModels; + use Dispatchable; + use InteractsWithQueue; + use Queueable; + use SerializesModels; private User $user; @@ -23,9 +32,14 @@ class DownloadSecurityLog implements ShouldQueue private ?int $userId; + public const CSV_SEPARATOR = ','; + public const EXPIRATION_HOURS = 24; + public const FORMAT_CSV = 'csv'; + public const FORMAT_XML = 'xml'; + /** * @param User $user - * @param string $format + * @param string $format xml|csv * @param int|null $userId */ public function __construct(User $user, string $format, int $userId = null) @@ -39,18 +53,215 @@ public function __construct(User $user, string $format, int $userId = null) * Execute the job. * * @return void + * @throw Exception */ public function handle() { - //1. Get all data from security_logs table - //2. Create a file in specified format: csv or xml - //3. Zip this file - //4. Store in S3, with private visibility, with 24h of lifecycle - //5. Send email to user with the link or error message - if (mt_rand(1, 10) <= 8) { - event(new SecurityLogDownloadJobCompleted($this->user, true, __('Click on the link and download the file. This link will be available until midnight tonight.'), 'http://processmaker.com/?download=true')); - } else { - event(new SecurityLogDownloadJobCompleted($this->user, false, __('Sorry, it was not possible to generate the log file. Please contact the administrator.'))); + // Check if the S3 is ready to use + if (!Media::s3IsReady()) { + event(new SecurityLogDownloadFailed($this->user, false, __('Sorry, this feature requires the configured AWS S3 service. Please contact the administrator.'))); + return; + } + try { + // Get the temp filename + $filename = $this->createTemporaryFilename(); + // Get the date of expiration + $expires = $this->getExpires(); + // Export the file and get the URL + $url = $this->export($filename, $expires); + $message = __('Click on the link and download the file. This link will be available until '. $expires->toString()); + // Call the event + event(new SecurityLogDownloadJobCompleted($this->user, true, $message, $url)); + } catch (Exception $e) { + $message = __('Sorry, it was not possible to connect AWS S3 service. Please contact the administrator.'); + event(new SecurityLogDownloadFailed($this->user, false, $e->getMessage())); + } + } + + /** + * Get expires time + * + * @return Carbon time + */ + protected function getExpires() + { + return now()->addHours(static::EXPIRATION_HOURS); + } + + /** + * Create a temp file + * + * @return string + */ + protected function createTemporaryFilename() + { + $uuid = Uuid::uuid4()->toString() . Str::random(8); + + return 'security-logs/' . $uuid . '.' . $this->format; + } + + /** + * Export the file and get the URL + * + * @param string $filename + * @param Carbon $expires + * + * @return URL + */ + protected function export(string $filename, Carbon $expires) + { + // Get a disk manager for S3 + $disk = Storage::disk('s3'); + + // Create a stream + $stream = fopen('php://temp', 'w+'); + + // Write the content + $stream = $this->writeContent($stream); + + // Rewind the stream + rewind($stream); + + // Save the stream to S3 + $disk->put($filename, stream_get_contents($stream), [ + 'ACL' => 'private', // private|public-read, + 'Expires' => $expires->toString() + ]); + + // Close the stream + fclose($stream); + + // Save temporary Url + $url = $disk->temporaryUrl( + $filename, + $expires, + [ + 'ResponseContentType' => 'application/octet-stream', + 'ResponseContentDisposition' => 'attachment; filename=' . $filename, + ] + ); + + return $url; + } + + /** + * Generate the content according to the format + * + * @return string + */ + protected function writeContent($stream) + { + $query = DB::table('security_logs'); + + // Check the filter per user + if ($this->userId) { + $query->where('user_id', $this->userId); + } + + // Initial tags for XML + $this->initialTagsXML($this->format === static::FORMAT_XML, $stream); + + // Use a cursor to iterate over the table data + $query->orderBy('id')->cursor()->each(function ($record) use ($stream) { + // Convert each record to an array and write it to the stream + $stream = $this->format === static::FORMAT_CSV ? $this->toCSV($stream, (array) $record) : $this->toXML($stream, (array) $record); + }); + + // End tags for XML + $this->endTagsXML($this->format === static::FORMAT_XML, $stream); + + return $stream; + } + + /** + * Write the CSV line + * + * @param string $stream + * @param array $record + * + * @return string + */ + protected function toCSV($stream, array $record) + { + fputcsv($stream, (array) $record, static::CSV_SEPARATOR); + + return $stream; + } + + /** + * Write the XML node + * + * @param string $stream + * @param array $record + * + * @return string + */ + protected function toXML($stream, array $record) + { + $content = $this->getXmlNode((array) $record); + fwrite($stream, $content); + + return $stream; + } + + /** + * Write the initial tags to XML + * + * @param bool $write + * @param string $stream + * + * @return void + */ + protected function initialTagsXML($write = false, $stream) + { + if ($write) { + $contentXml = '' . PHP_EOL; + $contentXml .= ''; + fwrite($stream, $contentXml); } } + + /** + * Write the end tags to XML + * + * @param bool $write + * @param string $stream + * + * @return void + */ + protected function endTagsXML($write = false, $stream) + { + if ($write) { + $contentXml = PHP_EOL . ''; + fwrite($stream, $contentXml); + } + } + + /** + * Get XML node + * + * @param array $item + * + * @return string + */ + protected function getXmlNode(array $item) + { + $tab = "\t"; + $content = PHP_EOL . $tab . ''; + foreach ($item as $key => $value) { + if (is_object($value)) { + $value = json_encode($value); + } + $content .= sprintf( + '%s<%s>%s', + PHP_EOL . $tab, + $key, + $value, + $key + ); + } + $content .= PHP_EOL . $tab . ''; + + return $content; + } } diff --git a/ProcessMaker/Models/Media.php b/ProcessMaker/Models/Media.php index e2a55c831a..75ad48d282 100644 --- a/ProcessMaker/Models/Media.php +++ b/ProcessMaker/Models/Media.php @@ -191,4 +191,15 @@ public static function getFilesRequest(ProcessRequest $request) // Get all files for process and all subprocesses .. return self::whereIn('model_id', $requestTokenIds)->get(); } + + /** + * Check if the S3 is ready to use + */ + public static function s3IsReady() + { + return config('filesystems.disks.s3.key') + && config('filesystems.disks.s3.secret') + && config('filesystems.disks.s3.region') + && config('filesystems.disks.s3.bucket'); + } } diff --git a/composer.json b/composer.json index ff92f28e57..766ab60d3f 100644 --- a/composer.json +++ b/composer.json @@ -30,6 +30,7 @@ "laravelcollective/html": "^6.3", "lavary/laravel-menu": "^1.8", "lcobucci/jwt": "^4.2", + "league/flysystem-aws-s3-v3": "^1.0", "mateusjunges/laravel-kafka": "^1.9", "microsoft/microsoft-graph": "^1.77", "moontoast/math": "^1.2", diff --git a/composer.lock b/composer.lock index bc2e41b51b..1b09d893c6 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "28c38d38f6d6836d788869f1b1b19435", + "content-hash": "ecf10e5a16c7ac3a853670610c981858", "packages": [ { "name": "asm89/stack-cors", @@ -62,6 +62,155 @@ }, "time": "2022-01-18T09:12:03+00:00" }, + { + "name": "aws/aws-crt-php", + "version": "v1.2.1", + "source": { + "type": "git", + "url": "https://github.com/awslabs/aws-crt-php.git", + "reference": "1926277fc71d253dfa820271ac5987bdb193ccf5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/1926277fc71d253dfa820271ac5987bdb193ccf5", + "reference": "1926277fc71d253dfa820271ac5987bdb193ccf5", + "shasum": "" + }, + "require": { + "php": ">=5.5" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35||^5.6.3||^9.5", + "yoast/phpunit-polyfills": "^1.0" + }, + "suggest": { + "ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality." + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "AWS SDK Common Runtime Team", + "email": "aws-sdk-common-runtime@amazon.com" + } + ], + "description": "AWS Common Runtime for PHP", + "homepage": "https://github.com/awslabs/aws-crt-php", + "keywords": [ + "amazon", + "aws", + "crt", + "sdk" + ], + "support": { + "issues": "https://github.com/awslabs/aws-crt-php/issues", + "source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.1" + }, + "time": "2023-03-24T20:22:19+00:00" + }, + { + "name": "aws/aws-sdk-php", + "version": "3.274.0", + "source": { + "type": "git", + "url": "https://github.com/aws/aws-sdk-php.git", + "reference": "b2f37a49ca40bce633323a6988477c22be562c37" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/b2f37a49ca40bce633323a6988477c22be562c37", + "reference": "b2f37a49ca40bce633323a6988477c22be562c37", + "shasum": "" + }, + "require": { + "aws/aws-crt-php": "^1.0.4", + "ext-json": "*", + "ext-pcre": "*", + "ext-simplexml": "*", + "guzzlehttp/guzzle": "^6.5.8 || ^7.4.5", + "guzzlehttp/promises": "^1.4.0", + "guzzlehttp/psr7": "^1.9.1 || ^2.4.5", + "mtdowling/jmespath.php": "^2.6", + "php": ">=5.5", + "psr/http-message": "^1.0" + }, + "require-dev": { + "andrewsville/php-token-reflection": "^1.4", + "aws/aws-php-sns-message-validator": "~1.0", + "behat/behat": "~3.0", + "composer/composer": "^1.10.22", + "dms/phpunit-arraysubset-asserts": "^0.4.0", + "doctrine/cache": "~1.4", + "ext-dom": "*", + "ext-openssl": "*", + "ext-pcntl": "*", + "ext-sockets": "*", + "nette/neon": "^2.3", + "paragonie/random_compat": ">= 2", + "phpunit/phpunit": "^4.8.35 || ^5.6.3 || ^9.5", + "psr/cache": "^1.0", + "psr/simple-cache": "^1.0", + "sebastian/comparator": "^1.2.3 || ^4.0", + "yoast/phpunit-polyfills": "^1.0" + }, + "suggest": { + "aws/aws-php-sns-message-validator": "To validate incoming SNS notifications", + "doctrine/cache": "To use the DoctrineCacheAdapter", + "ext-curl": "To send requests using cURL", + "ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages", + "ext-sockets": "To use client-side monitoring" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Aws\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Amazon Web Services", + "homepage": "http://aws.amazon.com" + } + ], + "description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project", + "homepage": "http://aws.amazon.com/sdkforphp", + "keywords": [ + "amazon", + "aws", + "cloud", + "dynamodb", + "ec2", + "glacier", + "s3", + "sdk" + ], + "support": { + "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80", + "issues": "https://github.com/aws/aws-sdk-php/issues", + "source": "https://github.com/aws/aws-sdk-php/tree/3.274.0" + }, + "time": "2023-06-27T18:32:17+00:00" + }, { "name": "babenkoivan/elastic-adapter", "version": "v3.5.0", @@ -3924,6 +4073,71 @@ ], "time": "2022-10-04T09:16:37+00:00" }, + { + "name": "league/flysystem-aws-s3-v3", + "version": "1.0.30", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git", + "reference": "af286f291ebab6877bac0c359c6c2cb017eb061d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/af286f291ebab6877bac0c359c6c2cb017eb061d", + "reference": "af286f291ebab6877bac0c359c6c2cb017eb061d", + "shasum": "" + }, + "require": { + "aws/aws-sdk-php": "^3.20.0", + "league/flysystem": "^1.0.40", + "php": ">=5.5.0" + }, + "require-dev": { + "henrikbjorn/phpspec-code-coverage": "~1.0.1", + "phpspec/phpspec": "^2.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Flysystem\\AwsS3v3\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frenky.net" + } + ], + "description": "Flysystem adapter for the AWS S3 SDK v3.x", + "support": { + "issues": "https://github.com/thephpleague/flysystem-aws-s3-v3/issues", + "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/1.0.30" + }, + "funding": [ + { + "url": "https://offset.earth/frankdejonge", + "type": "custom" + }, + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2022-07-02T13:51:38+00:00" + }, { "name": "league/fractal", "version": "0.20.1", @@ -4732,6 +4946,67 @@ "abandoned": "brick/math", "time": "2020-01-05T04:49:34+00:00" }, + { + "name": "mtdowling/jmespath.php", + "version": "2.6.1", + "source": { + "type": "git", + "url": "https://github.com/jmespath/jmespath.php.git", + "reference": "9b87907a81b87bc76d19a7fb2d61e61486ee9edb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/9b87907a81b87bc76d19a7fb2d61e61486ee9edb", + "reference": "9b87907a81b87bc76d19a7fb2d61e61486ee9edb", + "shasum": "" + }, + "require": { + "php": "^5.4 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.17" + }, + "require-dev": { + "composer/xdebug-handler": "^1.4 || ^2.0", + "phpunit/phpunit": "^4.8.36 || ^7.5.15" + }, + "bin": [ + "bin/jp.php" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.6-dev" + } + }, + "autoload": { + "files": [ + "src/JmesPath.php" + ], + "psr-4": { + "JmesPath\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "Declaratively specify how to extract elements from a JSON document", + "keywords": [ + "json", + "jsonpath" + ], + "support": { + "issues": "https://github.com/jmespath/jmespath.php/issues", + "source": "https://github.com/jmespath/jmespath.php/tree/2.6.1" + }, + "time": "2021-06-14T00:11:39+00:00" + }, { "name": "mustache/mustache", "version": "v2.14.2", diff --git a/tests/Feature/Jobs/DownloadSecurityLogTest.php b/tests/Feature/Jobs/DownloadSecurityLogTest.php new file mode 100644 index 0000000000..9011b5a94c --- /dev/null +++ b/tests/Feature/Jobs/DownloadSecurityLogTest.php @@ -0,0 +1,131 @@ +delete(); + + $this->simpleCollection = [ + ['id' => 1, 'event' => 'login'], + ['id' => 2, 'event' => 'logout'], + ]; + + SecurityLog::factory()->create(['event' => 'login', 'user_id' => $this->user->id]); + SecurityLog::factory()->create(['event' => 'logout', 'user_id' => $this->user->id]); + SecurityLog::factory()->create(['event' => 'attempt']); + SecurityLog::factory()->create(['event' => 'attempt']); + } + + public function testCreateTemporaryFilename() + { + $job = new DownloadSecurityLog($this->user, DownloadSecurityLog::FORMAT_CSV); + $method = new ReflectionMethod($job, 'createTemporaryFilename'); + $filename = $method->invoke($job); + $this->assertStringContainsString('.csv', $filename); + + $job = new DownloadSecurityLog($this->user, DownloadSecurityLog::FORMAT_XML); + $method = new ReflectionMethod($job, 'createTemporaryFilename'); + $filename = $method->invoke($job); + $this->assertStringContainsString('.xml', $filename); + } + + public function testExpires() + { + $job = new DownloadSecurityLog($this->user, DownloadSecurityLog::FORMAT_CSV); + $method = new ReflectionMethod($job, 'getExpires'); + $expires = $method->invoke($job); + $this->assertLessThan($expires, now()); + + $job = new DownloadSecurityLog($this->user, DownloadSecurityLog::FORMAT_XML); + $method = new ReflectionMethod($job, 'getExpires'); + $expires = $method->invoke($job); + $this->assertLessThan($expires, now()); + } + + /** + * @covers DownloadSecurityLog::toCSV + */ + public function testWriteContentCSV() + { + $stream = fopen('php://temp', 'w+'); + $job = new DownloadSecurityLog($this->user, DownloadSecurityLog::FORMAT_CSV); + $csv = (new ReflectionMethod($job, 'writeContent'))->invoke($job, $stream); + $this->assertNotEmpty($csv); + $this->assertTrue(rewind($stream)); + $this->assertTrue(fclose($stream)); + } + + /** + * @covers DownloadSecurityLog::initialTagsXML + * @covers DownloadSecurityLog::toXML + * @covers DownloadSecurityLog::endTagsXML + */ + public function testWriteContentXML() + { + $stream = fopen('php://temp', 'w+'); + $job = new DownloadSecurityLog($this->user, DownloadSecurityLog::FORMAT_XML); + $xml = (new ReflectionMethod($job, 'writeContent'))->invoke($job, $stream); + $this->assertNotEmpty($xml); + $this->assertTrue(rewind($stream)); + $this->assertTrue(fclose($stream)); + } + + public function testHandleWithSuccess() + { + if ( + !config('filesystems.disks.s3.key') + && !config('filesystems.disks.s3.secret') + && !config('filesystems.disks.s3.region') + && !config('filesystems.disks.s3.bucket') + ) { + $this->markTestSkipped( + 'AWS S3 service is not available.' + ); + } else { + $this->expectsEvents(SecurityLogDownloadJobCompleted::class); + $job = new DownloadSecurityLog($this->user, DownloadSecurityLog::FORMAT_CSV); + $url = (new ReflectionMethod($job, 'handle'))->invoke($job); + $this->assertNotEmpty($url); + $data = file_get_contents($url); + $this->assertNotEmpty($data); + } + } + + public function testExport() + { + if ( + !config('filesystems.disks.s3.key') + && !config('filesystems.disks.s3.secret') + && !config('filesystems.disks.s3.region') + && !config('filesystems.disks.s3.bucket') + ) { + $this->markTestSkipped( + 'AWS S3 service is not available.' + ); + } else { + $this->expectsEvents(SecurityLogDownloadJobCompleted::class); + $job = new DownloadSecurityLog($this->user, DownloadSecurityLog::FORMAT_CSV); + $filename = (new ReflectionMethod($job, 'createTemporaryFilename'))->invoke($job); + $expires = (new ReflectionMethod($job, 'getExpires'))->invoke($job); + $url = (new ReflectionMethod($job, 'export'))->invoke($job, $filename, $expires); + $this->assertNotEmpty($url); + $data = file_get_contents($url); + $this->assertNotEmpty($data); + } + } +}