From 5324a3e63741af68f5be168081d4b57920db3f22 Mon Sep 17 00:00:00 2001 From: William Allen Date: Thu, 8 Oct 2026 16:52:00 -0400 Subject: [PATCH] Factor out shared submission processing logic Follows #4088, factoring out the MD5 hash validation and deferred submission file manipulation into shared utility methods with unit tests. I also did some general code cleanup in a handful of spots. --- app/Http/Controllers/SubmissionController.php | 42 ++---- app/Utils/SubmissionUtils.php | 47 +++++++ app/Utils/UnparsedSubmissionProcessor.php | 64 ++++----- phpstan-baseline.neon | 54 -------- .../Submission/UnparsedSubmissionTest.php | 32 +++++ .../Feature/Submission/XmlSubmissionTest.php | 38 +++++ tests/Unit/Utils/SubmissionUtilsTest.php | 131 ++++++++++++++++++ 7 files changed, 289 insertions(+), 119 deletions(-) diff --git a/app/Http/Controllers/SubmissionController.php b/app/Http/Controllers/SubmissionController.php index 4e0785a24d..8a869f6ffa 100644 --- a/app/Http/Controllers/SubmissionController.php +++ b/app/Http/Controllers/SubmissionController.php @@ -17,14 +17,12 @@ use Exception; use Illuminate\Http\JsonResponse; use Illuminate\Http\Response; -use Illuminate\Support\Facades\Artisan; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Validator; use League\Flysystem\UnableToMoveFile; use League\Flysystem\UnableToReadFile; -use League\Flysystem\UnableToWriteFile; +use Symfony\Component\HttpFoundation\Exception\BadRequestException; use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException; use Symfony\Component\HttpKernel\Exception\HttpException; @@ -73,6 +71,7 @@ private function failProcessing(?string $filename, int $outResponseCode, string } /** + * @throws BadRequestException * @throws BadSubmissionException */ private function submitProcess(): Response @@ -83,7 +82,7 @@ private function submitProcess(): Response $responseMessage = ''; $projectname = request()->string('project', ''); - if (strlen($projectname) === 0) { + if (!request()->filled('project')) { Log::info('Rejected submission with no project name'); $this->failProcessing(null, Response::HTTP_BAD_REQUEST, 'No project name provided.'); } @@ -99,41 +98,32 @@ private function submitProcess(): Response $this->failProcessing(null, Response::HTTP_BAD_REQUEST, "Invalid project name: $projectname"); } - $expected_md5 = request()->query('MD5', ''); - if ($expected_md5 !== '' && !preg_match('/^[a-f0-9]{32}$/i', $expected_md5)) { + $expected_md5 = request()->query->getString('MD5'); + if (request()->filled('MD5') && !SubmissionUtils::isValidMD5($expected_md5)) { Log::info("Rejected submission with invalid hash '$expected_md5' for project $projectname"); $this->failProcessing(null, Response::HTTP_BAD_REQUEST, "Provided md5 hash '{$expected_md5}' is improperly formatted."); } // Get auth token (if any). - $authtoken = request()->bearerToken(); - $authtoken_hash = $authtoken === null || $authtoken === '' ? '' : AuthTokenService::hash($authtoken); + $authtoken_hash = AuthTokenService::hash(request()->bearerToken()); // Check that the md5sum of the file matches what we were told to expect. $fp = request()->getContent(true); - if (strlen($expected_md5) > 0) { + if (request()->filled('MD5') && !SubmissionUtils::isFileMD5Correct($fp, $expected_md5)) { $md5sum = SubmissionUtils::hashFileHandle($fp, 'md5'); - if ($md5sum !== $expected_md5) { - Log::info("Rejected submission because hash '$md5sum' does not match the expected hash '$expected_md5' for project $projectname"); - $this->failProcessing(null, Response::HTTP_BAD_REQUEST, "md5 mismatch. expected: {$expected_md5}, received: {$md5sum}"); - } + Log::info("Rejected submission because hash '$md5sum' does not match the expected hash '$expected_md5' for project $projectname"); + $this->failProcessing(null, Response::HTTP_BAD_REQUEST, "md5 mismatch. expected: {$expected_md5}, received: {$md5sum}"); } // Save the incoming file in the inbox directory. $filename = SubmissionUtils::xmlFilename((string) $projectname, $authtoken_hash, $expected_md5); - try { - Storage::put("inbox/{$filename}", $fp); - } catch (UnableToWriteFile $e) { - report($e); + if (!SubmissionUtils::storeInInbox($filename, $fp)) { $this->failProcessing($filename, Response::HTTP_INTERNAL_SERVER_ERROR, 'Failed to save submission file.'); } // Check if we can connect to the database before proceeding any further. if (!SystemUtils::isDatabaseOnline()) { - // Write a marker file so we know to process these files when the DB comes back up. - if (!Storage::exists('DB_WAS_DOWN')) { - Storage::put('DB_WAS_DOWN', ''); - } + SubmissionUtils::markDeferredSubmissions(); $statusarray['status'] = 'OK'; $statusarray['message'] = 'Database is unavailable.'; return self::displayXMLReturnStatus($statusarray); @@ -193,7 +183,7 @@ private function submitProcess(): Response // Check if CTest provided us enough info to assign a buildid. $buildid = null; - if (request()->has('build') && request()->has('site') && request()->has('stamp')) { + if (request()->filled('build') && request()->filled('site') && request()->filled('stamp')) { $build = new Build(); $build->Name = pdo_real_escape_string(request()->query('build')); $build->ProjectId = $this->project->Id; @@ -201,7 +191,7 @@ private function submitProcess(): Response $build->StartTime = gmdate(FMT_DATETIME); $build->SubmitTime = $build->StartTime; - if (request()->has('subproject')) { + if (request()->filled('subproject')) { $build->SetSubProject(pdo_real_escape_string(request()->query('subproject'))); } @@ -229,11 +219,7 @@ private function submitProcess(): Response fclose($fp); unset($fp); - // Check for marker file to see if we need to queue deferred submissions. - if (Storage::exists('DB_WAS_DOWN')) { - Storage::delete('DB_WAS_DOWN'); - Artisan::call('submission:queue'); - } + SubmissionUtils::queueDeferredSubmissions(); $statusarray['message'] = ''; if ($responseMessage !== '') { diff --git a/app/Utils/SubmissionUtils.php b/app/Utils/SubmissionUtils.php index a1f6e82dab..df045397e6 100644 --- a/app/Utils/SubmissionUtils.php +++ b/app/Utils/SubmissionUtils.php @@ -21,8 +21,11 @@ use CDash\Database; use CDash\Model\Build; use CDash\Model\BuildUpdate; +use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; +use League\Flysystem\UnableToWriteFile; class SubmissionUtils { @@ -331,6 +334,50 @@ public static function hashFileHandle(mixed $filehandle, string $algo): string return hash_final($hashContext); } + /** Check whether a string is a properly formatted md5 hash */ + public static function isValidMD5(string $md5): bool + { + return preg_match('/^[a-f0-9]{32}\z/i', $md5) === 1; + } + + /** Check whether the md5 of an open file handle matches the expected hash, ignoring case */ + public static function isFileMD5Correct(mixed $filehandle, string $expected_md5): bool + { + return strcasecmp(self::hashFileHandle($filehandle, 'md5'), $expected_md5) === 0; + } + + /** + * Save a submission file to the inbox directory + * + * @param resource|string $contents + */ + public static function storeInInbox(string $filename, mixed $contents): bool + { + try { + return Storage::put("inbox/{$filename}", $contents); + } catch (UnableToWriteFile $e) { + report($e); + return false; + } + } + + /** Record that the inbox holds submissions received while the database was unavailable */ + public static function markDeferredSubmissions(): void + { + if (!Storage::exists('DB_WAS_DOWN')) { + Storage::put('DB_WAS_DOWN', ''); + } + } + + /** Queue any submissions received while the database was unavailable */ + public static function queueDeferredSubmissions(): void + { + if (Storage::exists('DB_WAS_DOWN')) { + Storage::delete('DB_WAS_DOWN'); + Artisan::call('submission:queue'); + } + } + /** Generate the inbox filename for a CTest XML submission */ public static function xmlFilename(string $projectname, string $token_hash, string $md5): string { diff --git a/app/Utils/UnparsedSubmissionProcessor.php b/app/Utils/UnparsedSubmissionProcessor.php index 92d5837cb0..32f7acb76e 100644 --- a/app/Utils/UnparsedSubmissionProcessor.php +++ b/app/Utils/UnparsedSubmissionProcessor.php @@ -21,14 +21,15 @@ use App\Jobs\ProcessSubmission; use App\Models\BuildFile; use App\Models\PendingSubmissions; +use App\Models\Project as EloquentProject; use App\Models\Site; use App\Rules\ProjectNameRule; use App\Services\AuthTokenService; use CDash\Model\Build; use CDash\Model\Project; +use Closure; use Exception; use Illuminate\Http\JsonResponse; -use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; @@ -98,10 +99,7 @@ public function postSubmit(): JsonResponse $uuid = Str::uuid()->toString(); $this->serializeBuildMetadata($uuid); - // Write a marker file so we know to process these files when the DB comes back up. - if (!Storage::exists('DB_WAS_DOWN')) { - Storage::put('DB_WAS_DOWN', ''); - } + SubmissionUtils::markDeferredSubmissions(); // Respond with success even though the database is down so that CTest will // proceed to upload the data file. @@ -145,17 +143,15 @@ public function parseBuildMetadata(): void public function initializeBuild(): JsonResponse { // Retrieve this project from the database. - $project_row = DB::table('project') - ->where('name', $this->projectname) - ->first(); + $eloquent_project = EloquentProject::firstWhere('name', $this->projectname); - if (!$project_row) { + if ($eloquent_project === null) { abort(Response::HTTP_NOT_FOUND, 'Project does not exist'); } - $projectid = $project_row->id; + $projectid = $eloquent_project->id; // Check if this submission requires a valid authentication token. - if (($this->token || $project_row->authenticatesubmissions) && !AuthTokenService::check($this->token, $projectid)) { + if (($this->token || $eloquent_project->authenticatesubmissions) && !AuthTokenService::check($this->token, $projectid)) { abort(Response::HTTP_FORBIDDEN, 'Forbidden'); } @@ -168,7 +164,7 @@ public function initializeBuild(): JsonResponse // Add the build. $this->build = new Build(); - $this->build->ProjectId = get_project_id($this->projectname); + $this->build->ProjectId = $projectid; $this->build->StartTime = gmdate(FMT_DATETIME, $this->starttime); $this->build->EndTime = gmdate(FMT_DATETIME, $this->endtime); $this->build->SubmitTime = gmdate(FMT_DATETIME); @@ -244,8 +240,6 @@ public function putSubmitFile(): JsonResponse abort(Response::HTTP_BAD_REQUEST, $validator->errors()->first()); } $this->projectname = $this->project->Name; - - $this->inboxdatafilename = 'inbox/' . SubmissionUtils::dataFilename($this->projectname, $this->token, $this->type, $this->buildid, $this->md5, $ext); } else { // Get project name from build metadata file on disk. $projectname = null; @@ -264,18 +258,19 @@ public function putSubmitFile(): JsonResponse abort(Response::HTTP_NOT_FOUND, 'Build not found'); } $this->projectname = $projectname; - $this->inboxdatafilename = 'inbox/' . SubmissionUtils::dataFilename($this->projectname, $this->token, $this->type, $this->buildid, $this->md5, $ext); - $this->serializeDataFileParameters(); + } - if (!Storage::exists('DB_WAS_DOWN')) { - Storage::put('DB_WAS_DOWN', ''); - } + $filename = SubmissionUtils::dataFilename($this->projectname, $this->token, $this->type, $this->buildid, $this->md5, $ext); + $this->inboxdatafilename = 'inbox/' . $filename; + + if (!$db_up) { + $this->serializeDataFileParameters(); + SubmissionUtils::markDeferredSubmissions(); } // Write this file to the inbox directory. - $handle = request()->getContent(true); - if (!Storage::put($this->inboxdatafilename, $handle)) { - abort(Response::HTTP_INTERNAL_SERVER_ERROR, "Cannot open file ($this->inboxdatafilename)"); + if (!SubmissionUtils::storeInInbox($filename, request()->getContent(true))) { + abort(Response::HTTP_INTERNAL_SERVER_ERROR, 'Failed to save submission file.'); } if (!$db_up) { @@ -287,14 +282,9 @@ public function putSubmitFile(): JsonResponse // THis function will throw an exception if invalid data provided $this->populateBuildFileRow(); - $filename = str_replace('inbox/', '', $this->inboxdatafilename); ProcessSubmission::dispatch($filename, $this->project->Id, $this->build->Id, $this->md5); - // Check for marker file to see if we need to queue deferred submissions. - if (Storage::exists('DB_WAS_DOWN')) { - Storage::delete('DB_WAS_DOWN'); - Artisan::call('submission:queue'); - } + SubmissionUtils::queueDeferredSubmissions(); return response()->json($response_array); } @@ -325,8 +315,9 @@ public function populateBuildFileRow(): void } // Check that the md5sum of the file matches what we were expecting. - $md5sum = SubmissionUtils::hashFileHandle(Storage::readStream($this->inboxdatafilename), 'md5'); - if ($md5sum != $this->md5) { + $stream = Storage::readStream($this->inboxdatafilename); + if (!SubmissionUtils::isFileMD5Correct($stream, $this->md5)) { + $md5sum = SubmissionUtils::hashFileHandle($stream, 'md5'); Storage::delete($this->inboxdatafilename); $buildfile->delete(); abort(Response::HTTP_BAD_REQUEST, "md5 mismatch. expected: {$this->md5}, received: $md5sum"); @@ -350,7 +341,11 @@ public function parseDataFileParameters(): void $validator = Validator::make(request()->query(), [ 'buildid' => 'required', 'type' => ['required', Rule::enum(UnparsedSubmissionType::class)], - 'md5' => ['required', 'regex:/^[a-f0-9]{32}$/i'], + 'md5' => ['required', function (string $attribute, mixed $value, Closure $fail): void { + if (!is_string($value) || !SubmissionUtils::isValidMD5($value)) { + $fail('The :attribute format is invalid.'); + } + }], 'filename' => 'required', ]); @@ -455,11 +450,6 @@ private function getAuthTokenHash(): void if ($this->token) { return; } - $token = request()->bearerToken(); - if ($token) { - $this->token = AuthTokenService::hash($token); - } else { - $this->token = ''; - } + $this->token = AuthTokenService::hash(request()->bearerToken()); } } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index d465221ea3..013aa68d1f 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1404,42 +1404,6 @@ parameters: count: 3 path: app/Http/Controllers/SubmissionController.php - - - rawMessage: 'Only booleans are allowed in a negated boolean, int|false given.' - identifier: booleanNot.exprNotBoolean - count: 1 - path: app/Http/Controllers/SubmissionController.php - - - - rawMessage: 'Parameter #1 $string of function strlen expects string, (array|string) given.' - identifier: argument.type - count: 1 - path: app/Http/Controllers/SubmissionController.php - - - - rawMessage: 'Parameter #2 $subject of function preg_match expects string, (array|string) given.' - identifier: argument.type - count: 1 - path: app/Http/Controllers/SubmissionController.php - - - - rawMessage: 'Parameter #3 $md5 of static method App\Utils\SubmissionUtils::xmlFilename() expects string, (array|string) given.' - identifier: argument.type - count: 1 - path: app/Http/Controllers/SubmissionController.php - - - - rawMessage: 'Part $expected_md5 (array|non-empty-string) of encapsed string cannot be cast to string.' - identifier: encapsedStringPart.nonString - count: 2 - path: app/Http/Controllers/SubmissionController.php - - - - rawMessage: 'Part $expected_md5 (array|string) of encapsed string cannot be cast to string.' - identifier: encapsedStringPart.nonString - count: 2 - path: app/Http/Controllers/SubmissionController.php - - rawMessage: ''' Call to deprecated method executePrepared() of class CDash\Database: @@ -6030,12 +5994,6 @@ parameters: count: 2 path: app/Utils/UnparsedSubmissionProcessor.php - - - rawMessage: 'Loose comparison via "!=" between string and mixed is not allowed.' - identifier: notEqual.notAllowed - count: 1 - path: app/Utils/UnparsedSubmissionProcessor.php - - rawMessage: 'Method App\Utils\UnparsedSubmissionProcessor::deserializeBuildMetadata() has parameter $fp with no type specified.' identifier: missingType.parameter @@ -6054,18 +6012,6 @@ parameters: count: 1 path: app/Utils/UnparsedSubmissionProcessor.php - - - rawMessage: 'Only booleans are allowed in a negated boolean, stdClass|null given.' - identifier: booleanNot.exprNotBoolean - count: 1 - path: app/Utils/UnparsedSubmissionProcessor.php - - - - rawMessage: 'Only booleans are allowed in an if condition, string|null given.' - identifier: if.condNotBoolean - count: 1 - path: app/Utils/UnparsedSubmissionProcessor.php - - rawMessage: 'Parameter #1 $json of function json_decode expects string, (string|false) given.' identifier: argument.type diff --git a/tests/Feature/Submission/UnparsedSubmissionTest.php b/tests/Feature/Submission/UnparsedSubmissionTest.php index 3ece4f4d42..7c5535fd38 100644 --- a/tests/Feature/Submission/UnparsedSubmissionTest.php +++ b/tests/Feature/Submission/UnparsedSubmissionTest.php @@ -564,6 +564,38 @@ public function testAcceptsDataFile(): void && $job->expected_md5 === $md5); } + public function testAcceptsDataFileWithUppercaseMd5(): void + { + $buildid = $this->createBuild(); + $md5 = strtoupper(md5(self::FILE_CONTENTS)); + + $response = $this->putDataFile([ + ...$this->dataFileParameters($buildid), + 'md5' => $md5, + ]); + + $response->assertOk(); + $response->assertExactJson(['status' => 0]); + $filename = "{$this->project->name}_-__-_BazelJSON_-_{$buildid}_-_{$md5}_-_.json"; + $this->assertSame(["inbox/$filename"], Storage::allFiles()); + $this->assertSame(self::FILE_CONTENTS, Storage::get("inbox/$filename")); + $this->assertDatabaseHas('buildfile', [ + 'buildid' => $buildid, + 'type' => 'BazelJSON', + 'md5' => $md5, + 'filename' => 'bazel.json', + ]); + $this->assertDatabaseHas('pending_submissions', [ + 'buildid' => $buildid, + 'numfiles' => 1, + ]); + Queue::assertCount(1); + Queue::assertPushed(ProcessSubmission::class, fn (ProcessSubmission $job) => $job->filename === $filename + && $job->projectid === $this->project->id + && $job->buildid === $buildid + && $job->expected_md5 === $md5); + } + public function testRejectsDataFileWithoutTokenWhenProjectRequiresAuthentication(): void { $this->requireAuthenticatedSubmissions(); diff --git a/tests/Feature/Submission/XmlSubmissionTest.php b/tests/Feature/Submission/XmlSubmissionTest.php index 19d9d7ed0a..28a3ba0365 100644 --- a/tests/Feature/Submission/XmlSubmissionTest.php +++ b/tests/Feature/Submission/XmlSubmissionTest.php @@ -191,6 +191,29 @@ public function testAcceptsSubmissionWithMd5(): void && $job->expected_md5 === $md5); } + public function testAcceptsSubmissionWithUppercaseMd5(): void + { + $file_to_submit = base_path('tests/Feature/Submission/data/configure.xml'); + $md5 = md5_file($file_to_submit); + $this->assertIsString($md5); + $md5 = strtoupper($md5); + + $response = $this->submit($file_to_submit, [ + 'project' => $this->project->name, + 'MD5' => $md5, + ]); + + $response->assertOk(); + $this->assertXmlResponse($response, ' + + OK + '); + Queue::assertCount(1); + Queue::assertPushed(ProcessSubmission::class, fn (ProcessSubmission $job) => $job->projectid === $this->project->id + && $job->buildid === null + && $job->expected_md5 === $md5); + } + public function testAcceptsSubmissionWithBuildMetadata(): void { $response = $this->submit(base_path('tests/Feature/Submission/data/configure.xml'), [ @@ -232,6 +255,21 @@ public static function partialBuildMetadataCases(): array 'build' => 'regular_submission', 'site' => 'submission.site', ]], + 'with blank build' => [[ + 'build' => '', + 'site' => 'submission.site', + 'stamp' => '20240501-0100-Nightly', + ]], + 'with blank site' => [[ + 'build' => 'regular_submission', + 'site' => '', + 'stamp' => '20240501-0100-Nightly', + ]], + 'with blank stamp' => [[ + 'build' => 'regular_submission', + 'site' => 'submission.site', + 'stamp' => '', + ]], ]; } diff --git a/tests/Unit/Utils/SubmissionUtilsTest.php b/tests/Unit/Utils/SubmissionUtilsTest.php index 5ef1abc4bc..63a7316590 100644 --- a/tests/Unit/Utils/SubmissionUtilsTest.php +++ b/tests/Unit/Utils/SubmissionUtilsTest.php @@ -3,6 +3,10 @@ namespace Tests\Unit\Utils; use App\Utils\SubmissionUtils; +use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Exceptions; +use Illuminate\Support\Facades\Storage; +use League\Flysystem\UnableToWriteFile; use PHPUnit\Framework\Attributes\DataProvider; use Tests\TestCase; @@ -93,4 +97,131 @@ public function testParseFilenameRejectsMalformedFilename(string $filename): voi { $this->assertNull(SubmissionUtils::parseFilename($filename)); } + + /** + * @return array + */ + public static function wellFormedMd5s(): array + { + return [ + 'lowercase' => [self::MD5], + 'uppercase' => [strtoupper(self::MD5)], + ]; + } + + #[DataProvider('wellFormedMd5s')] + public function testIsValidMD5AcceptsWellFormedHash(string $md5): void + { + $this->assertTrue(SubmissionUtils::isValidMD5($md5)); + } + + /** + * @return array + */ + public static function malformedMd5s(): array + { + return [ + 'empty' => [''], + 'too short' => [substr(self::MD5, 1)], + 'too long' => [self::MD5 . 'f'], + 'not hexadecimal' => ['z' . substr(self::MD5, 1)], + 'path' => ['../' . substr(self::MD5, 3)], + 'trailing newline' => [self::MD5 . "\n"], + ]; + } + + #[DataProvider('malformedMd5s')] + public function testIsValidMD5RejectsMalformedHash(string $md5): void + { + $this->assertFalse(SubmissionUtils::isValidMD5($md5)); + } + + public function testIsFileMD5CorrectAcceptsMatchingHash(): void + { + $stream = $this->makeStream('contents'); + + $this->assertTrue(SubmissionUtils::isFileMD5Correct($stream, md5('contents'))); + $this->assertSame('contents', stream_get_contents($stream)); + } + + public function testIsFileMD5CorrectAcceptsUppercaseMatchingHash(): void + { + $stream = $this->makeStream('contents'); + + $this->assertTrue(SubmissionUtils::isFileMD5Correct($stream, strtoupper(md5('contents')))); + } + + public function testIsFileMD5CorrectRejectsMismatchedHash(): void + { + $stream = $this->makeStream('contents'); + + $this->assertFalse(SubmissionUtils::isFileMD5Correct($stream, self::MD5)); + } + + public function testStoreInInbox(): void + { + Storage::fake(); + + $this->assertTrue(SubmissionUtils::storeInInbox('file.xml', $this->makeStream('contents'))); + + $this->assertSame(['inbox/file.xml'], Storage::allFiles()); + $this->assertSame('contents', Storage::get('inbox/file.xml')); + } + + public function testStoreInInboxReturnsFalseWhenWriteFails(): void + { + Exceptions::fake(); + Storage::shouldReceive('put') + ->once() + ->with('inbox/file.xml', 'contents') + ->andThrow(UnableToWriteFile::atLocation('inbox/file.xml')); + + $this->assertFalse(SubmissionUtils::storeInInbox('file.xml', 'contents')); + + Exceptions::assertReported(UnableToWriteFile::class); + } + + public function testMarkDeferredSubmissions(): void + { + Storage::fake(); + + SubmissionUtils::markDeferredSubmissions(); + + $this->assertSame(['DB_WAS_DOWN'], Storage::allFiles()); + } + + public function testQueueDeferredSubmissions(): void + { + Storage::fake(); + Storage::put('DB_WAS_DOWN', ''); + $artisan = Artisan::spy(); + + SubmissionUtils::queueDeferredSubmissions(); + + $this->assertSame([], Storage::allFiles()); + $artisan->shouldHaveReceived('call')->once()->with('submission:queue'); + } + + public function testQueueDeferredSubmissionsDoesNothingWithoutMarker(): void + { + Storage::fake(); + $artisan = Artisan::spy(); + + SubmissionUtils::queueDeferredSubmissions(); + + $this->assertSame([], Storage::allFiles()); + $artisan->shouldNotHaveReceived('call'); + } + + /** + * @return resource + */ + private function makeStream(string $contents) + { + $stream = fopen('php://memory', 'r+'); + $this->assertIsResource($stream); + fwrite($stream, $contents); + rewind($stream); + return $stream; + } }