diff --git a/app/Enums/UnparsedSubmissionType.php b/app/Enums/UnparsedSubmissionType.php new file mode 100644 index 0000000000..10eb763e0d --- /dev/null +++ b/app/Enums/UnparsedSubmissionType.php @@ -0,0 +1,18 @@ +Handler. + */ +enum UnparsedSubmissionType: string +{ + case BAZEL_JSON = 'BazelJSON'; + case BUILD_PROPERTIES_JSON = 'BuildPropertiesJSON'; + case GCOV_TAR = 'GcovTar'; + case JAVA_JSON_TAR = 'JavaJSONTar'; + case JSCOVER_TAR = 'JSCoverTar'; + case OPENCOVER_TAR = 'OpenCoverTar'; + case SUBPROJECT_DIRECTORIES = 'SubProjectDirectories'; +} diff --git a/app/Jobs/ProcessSubmission.php b/app/Jobs/ProcessSubmission.php index 489236a254..3f2c930631 100644 --- a/app/Jobs/ProcessSubmission.php +++ b/app/Jobs/ProcessSubmission.php @@ -2,6 +2,7 @@ namespace App\Jobs; +use App\Enums\UnparsedSubmissionType; use App\Exceptions\BadSubmissionException; use App\Http\Submission\Handlers\AbstractSubmissionHandler; use App\Http\Submission\Handlers\ActionableBuildInterface; @@ -297,23 +298,15 @@ private static function parse_put_submission(string $filename, \App\Models\Proje // Save a backup file for this submission. // Include the handler file for this type of submission. - $valid_types = [ - 'BazelJSON', - 'BuildPropertiesJSON', - 'GcovTar', - 'JavaJSONTar', - 'JSCoverTar', - 'OpenCoverTar', - 'SubProjectDirectories', - ]; - if (!in_array($buildfile->type, $valid_types, true)) { + $type = UnparsedSubmissionType::tryFrom($buildfile->type); + if ($type === null) { Log::error("No handler include file for {$buildfile->type}"); $buildfile->delete(); return false; } // Instantiate the handler. - $className = 'App\\Http\\Submission\\Handlers\\' . $buildfile->type . 'Handler'; + $className = 'App\\Http\\Submission\\Handlers\\' . $type->value . 'Handler'; if (!class_exists($className)) { Log::error("No handler class for {$buildfile->type}"); $buildfile->delete(); diff --git a/app/Utils/UnparsedSubmissionProcessor.php b/app/Utils/UnparsedSubmissionProcessor.php index a437a6c794..f05062f4ce 100644 --- a/app/Utils/UnparsedSubmissionProcessor.php +++ b/app/Utils/UnparsedSubmissionProcessor.php @@ -17,6 +17,7 @@ namespace App\Utils; +use App\Enums\UnparsedSubmissionType; use App\Jobs\ProcessSubmission; use App\Models\BuildFile; use App\Models\PendingSubmissions; @@ -33,6 +34,7 @@ use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Validator; use Illuminate\Support\Str; +use Illuminate\Validation\Rule; use Symfony\Component\HttpFoundation\Response; /** @@ -118,8 +120,8 @@ public function parseBuildMetadata(): void 'build' => 'required', 'stamp' => 'required', 'site' => 'required', - 'starttime' => 'required', - 'endtime' => 'required', + 'starttime' => 'required|integer', + 'endtime' => 'required|integer', 'datafilesmd5' => 'required', ]); @@ -216,9 +218,11 @@ public function putSubmitFile(): JsonResponse $db_up = SystemUtils::isDatabaseOnline(); if ($db_up) { - if (!is_numeric($this->buildid) || $this->buildid < 1) { + $buildid = filter_var($this->buildid, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); + if ($buildid === false) { abort(Response::HTTP_NOT_FOUND, 'Build not found'); } + $this->buildid = $buildid; // Get the relevant build and project. $this->build = new Build(); $this->build->Id = $this->buildid; @@ -352,10 +356,12 @@ public function populateBuildFileRow(): void public function parseDataFileParameters(): void { + // The build id is validated later: it is a UUID instead of an integer if the build metadata + // was submitted while the database was unavailable. $validator = Validator::make(request()->query(), [ 'buildid' => 'required', - 'type' => 'required', - 'md5' => 'required', + 'type' => ['required', Rule::enum(UnparsedSubmissionType::class)], + 'md5' => ['required', 'regex:/^[a-f0-9]{32}$/i'], 'filename' => 'required', ]); diff --git a/app/cdash/tests/CMakeLists.txt b/app/cdash/tests/CMakeLists.txt index 50f90ca1fe..8d971886e8 100644 --- a/app/cdash/tests/CMakeLists.txt +++ b/app/cdash/tests/CMakeLists.txt @@ -335,6 +335,8 @@ add_feature_test_in_transaction(/Feature/AutoRemoveBuildsCommand) add_feature_test_in_transaction(/Feature/Submission/XmlSubmissionTest) +add_feature_test_in_transaction(/Feature/Submission/UnparsedSubmissionTest) + add_feature_test_in_transaction(/Feature/Submission/XmlValidationTest) add_feature_test_in_transaction(/Feature/Submission/Test/TestXMLTest) diff --git a/tests/Feature/Submission/UnparsedSubmissionTest.php b/tests/Feature/Submission/UnparsedSubmissionTest.php new file mode 100644 index 0000000000..3ece4f4d42 --- /dev/null +++ b/tests/Feature/Submission/UnparsedSubmissionTest.php @@ -0,0 +1,631 @@ +project = $this->makePublicProject(); + } + + /** + * @return array + */ + private function buildMetadata(): array + { + return [ + 'project' => $this->project->name, + 'build' => 'unparsed_submission', + 'site' => 'unparsed.site', + 'stamp' => '20240501-0100-Experimental', + 'starttime' => 1714525200, + 'endtime' => 1714525260, + 'datafilesmd5' => [md5(self::FILE_CONTENTS)], + ]; + } + + /** + * @return array + */ + private function dataFileParameters(int $buildid): array + { + return [ + 'buildid' => $buildid, + 'type' => 'BazelJSON', + 'md5' => md5(self::FILE_CONTENTS), + 'filename' => 'bazel.json', + ]; + } + + /** + * @param array $build_metadata + * + * @return TestResponse + */ + private function postBuildMetadata(array $build_metadata, ?string $auth_token = null): TestResponse + { + return $this->call('POST', '/submit.php', $build_metadata, [], [], $this->authorizationHeader($auth_token)); + } + + /** + * @param array $query + * + * @return TestResponse + */ + private function putDataFile(array $query, ?string $auth_token = null): TestResponse + { + // PUT request parameters are placed in the request body, so these must go in the URL instead. + return $this->call('PUT', '/submit.php?' . http_build_query($query), [], [], [], $this->authorizationHeader($auth_token), self::FILE_CONTENTS); + } + + /** + * @return array + */ + private function authorizationHeader(?string $auth_token): array + { + return $auth_token === null + ? [] + : $this->transformHeadersToServerVars(['Authorization' => "Bearer $auth_token"]); + } + + private function createBuild(): int + { + return $this->project->builds()->create([ + 'name' => 'unparsed_submission', + 'uuid' => Str::uuid()->toString(), + ])->id; + } + + private function createSubmitOnlyToken(): string + { + return AuthTokenService::generate( + User::factory()->create()->id, + $this->project->id, + AuthTokenScope::SUBMIT_ONLY, + 'Unparsed submission test token', + )['raw_token']; + } + + private function requireAuthenticatedSubmissions(): void + { + $this->project->authenticatesubmissions = true; + $this->project->save(); + } + + public function testRejectsBuildMetadataWithEmptyProjectName(): void + { + // Requests without a project parameter are treated as XML submissions, so an empty project + // name is needed to reach the unparsed submission processor. + $response = $this->postBuildMetadata([ + ...$this->buildMetadata(), + 'project' => '', + ]); + + $response->assertBadRequest(); + $response->assertExactJson([ + 'status' => 1, + 'description' => 'The project field is required.', + ]); + $this->assertDatabaseMissing('build', ['projectid' => $this->project->id]); + Queue::assertNothingPushed(); + $this->assertSame([], Storage::allFiles()); + } + + /** + * @return array + */ + public static function invalidProjectNameCases(): array + { + return [ + 'invalid characters' => [ + 'invalid/name', + 'Project name may only contain letters, numbers, dashes, and underscores.', + ], + 'filename separator' => [ + 'invalid_-_name', + 'Project name must not contain string "_-_"', + ], + ]; + } + + #[DataProvider('invalidProjectNameCases')] + public function testRejectsBuildMetadataWithInvalidProjectName(string $project_name, string $message): void + { + $response = $this->postBuildMetadata([ + ...$this->buildMetadata(), + 'project' => $project_name, + ]); + + $response->assertBadRequest(); + $response->assertExactJson([ + 'status' => 1, + 'description' => $message, + ]); + $this->assertDatabaseMissing('build', ['projectid' => $this->project->id]); + Queue::assertNothingPushed(); + $this->assertSame([], Storage::allFiles()); + } + + public function testRejectsBuildMetadataForNonexistentProject(): void + { + $response = $this->postBuildMetadata([ + ...$this->buildMetadata(), + 'project' => 'NonexistentProject_' . Str::uuid()->toString(), + ]); + + $response->assertNotFound(); + $response->assertExactJson([ + 'status' => 1, + 'description' => 'Project does not exist', + ]); + $this->assertDatabaseMissing('build', ['name' => 'unparsed_submission']); + Queue::assertNothingPushed(); + $this->assertSame([], Storage::allFiles()); + } + + /** + * @return array + */ + public static function requiredBuildMetadataCases(): array + { + return [ + 'build' => ['build'], + 'site' => ['site'], + 'stamp' => ['stamp'], + 'starttime' => ['starttime'], + 'endtime' => ['endtime'], + 'datafilesmd5' => ['datafilesmd5'], + ]; + } + + #[DataProvider('requiredBuildMetadataCases')] + public function testRejectsBuildMetadataWithMissingField(string $field): void + { + $build_metadata = $this->buildMetadata(); + unset($build_metadata[$field]); + + $response = $this->postBuildMetadata($build_metadata); + + $response->assertBadRequest(); + $response->assertExactJson([ + 'status' => 1, + 'description' => "The $field field is required.", + ]); + $this->assertDatabaseMissing('build', ['projectid' => $this->project->id]); + Queue::assertNothingPushed(); + $this->assertSame([], Storage::allFiles()); + } + + /** + * @return array + */ + public static function nonIntegerTimestampCases(): array + { + return [ + 'non-numeric starttime' => ['starttime', 'abc'], + 'formatted starttime' => ['starttime', '2024-05-01 01:00:00'], + 'fractional endtime' => ['endtime', '1714525260.5'], + ]; + } + + #[DataProvider('nonIntegerTimestampCases')] + public function testRejectsBuildMetadataWithNonIntegerTimestamp(string $field, string $value): void + { + $response = $this->postBuildMetadata([ + ...$this->buildMetadata(), + $field => $value, + ]); + + $response->assertBadRequest(); + $response->assertExactJson([ + 'status' => 1, + 'description' => "The $field must be an integer.", + ]); + $this->assertDatabaseMissing('build', ['projectid' => $this->project->id]); + Queue::assertNothingPushed(); + $this->assertSame([], Storage::allFiles()); + } + + public function testAcceptsBuildMetadata(): void + { + $response = $this->postBuildMetadata([ + ...$this->buildMetadata(), + 'generator' => 'ctest-3.30.0', + ]); + + $response->assertOk(); + $this->assertSame(1, $this->project->builds()->count()); + $build = $this->project->builds()->firstOrFail(); + $response->assertExactJson([ + 'status' => 0, + 'buildid' => $build->id, + 'datafilesmd5' => [0], + ]); + $this->assertSame('unparsed_submission', $build->name); + $this->assertSame('20240501-0100-Experimental', $build->stamp); + $this->assertSame('ctest-3.30.0', $build->generator); + $this->assertSame('2024-05-01 01:00:00', $build->starttime->format('Y-m-d H:i:s')); + $this->assertSame('2024-05-01 01:01:00', $build->endtime->format('Y-m-d H:i:s')); + $this->assertSame(Site::where('name', 'unparsed.site')->firstOrFail()->id, $build->siteid); + // The data file hasn't been uploaded yet, so there is nothing to process. + Queue::assertNothingPushed(); + $this->assertSame([], Storage::allFiles()); + } + + public function testReusesBuildForRepeatedBuildMetadata(): void + { + $first_response = $this->postBuildMetadata($this->buildMetadata()); + $second_response = $this->postBuildMetadata($this->buildMetadata()); + + $first_response->assertOk(); + $second_response->assertOk(); + $this->assertSame(1, $this->project->builds()->count()); + $buildid = $this->project->builds()->firstOrFail()->id; + $this->assertSame($buildid, $first_response->json('buildid')); + $this->assertSame($buildid, $second_response->json('buildid')); + } + + public function testRejectsBuildMetadataWithoutTokenWhenProjectRequiresAuthentication(): void + { + $this->requireAuthenticatedSubmissions(); + + $response = $this->postBuildMetadata($this->buildMetadata()); + + $response->assertForbidden(); + $response->assertExactJson([ + 'status' => 1, + 'description' => 'Forbidden', + ]); + $this->assertDatabaseMissing('build', ['projectid' => $this->project->id]); + Queue::assertNothingPushed(); + $this->assertSame([], Storage::allFiles()); + } + + public function testRejectsBuildMetadataWithUnknownTokenWhenProjectRequiresAuthentication(): void + { + $this->requireAuthenticatedSubmissions(); + + $response = $this->postBuildMetadata($this->buildMetadata(), Str::uuid()->toString()); + + $response->assertForbidden(); + $response->assertExactJson([ + 'status' => 1, + 'description' => 'Forbidden', + ]); + $this->assertDatabaseMissing('build', ['projectid' => $this->project->id]); + Queue::assertNothingPushed(); + $this->assertSame([], Storage::allFiles()); + } + + public function testAcceptsBuildMetadataWithValidTokenWhenProjectRequiresAuthentication(): void + { + $this->requireAuthenticatedSubmissions(); + + $response = $this->postBuildMetadata($this->buildMetadata(), $this->createSubmitOnlyToken()); + + $response->assertOk(); + $this->assertSame(1, $this->project->builds()->count()); + $response->assertExactJson([ + 'status' => 0, + 'buildid' => $this->project->builds()->firstOrFail()->id, + 'datafilesmd5' => [0], + ]); + } + + /** + * Unlike XML submissions and data file uploads, build metadata is rejected whenever an invalid + * token is presented, even if the project doesn't require one. + */ + public function testRejectsBuildMetadataWithUnknownTokenWhenProjectDoesNotRequireAuthentication(): void + { + $response = $this->postBuildMetadata($this->buildMetadata(), Str::uuid()->toString()); + + $response->assertForbidden(); + $response->assertExactJson([ + 'status' => 1, + 'description' => 'Forbidden', + ]); + $this->assertDatabaseMissing('build', ['projectid' => $this->project->id]); + Queue::assertNothingPushed(); + $this->assertSame([], Storage::allFiles()); + } + + public function testRejectsDataFileWithEmptyBuildId(): void + { + // Requests without a buildid parameter are treated as XML submissions, so an empty build id + // is needed to reach the unparsed submission processor. + $response = $this->putDataFile([ + ...$this->dataFileParameters($this->createBuild()), + 'buildid' => '', + ]); + + $response->assertBadRequest(); + $response->assertExactJson([ + 'status' => 1, + 'description' => 'The buildid field is required.', + ]); + $this->assertDatabaseMissing('buildfile', ['md5' => md5(self::FILE_CONTENTS)]); + Queue::assertNothingPushed(); + $this->assertSame([], Storage::allFiles()); + } + + /** + * @return array + */ + public static function requiredDataFileParameterCases(): array + { + return [ + 'type' => ['type'], + 'md5' => ['md5'], + 'filename' => ['filename'], + ]; + } + + #[DataProvider('requiredDataFileParameterCases')] + public function testRejectsDataFileWithMissingParameter(string $parameter): void + { + $buildid = $this->createBuild(); + $query = $this->dataFileParameters($buildid); + unset($query[$parameter]); + + $response = $this->putDataFile($query); + + $response->assertBadRequest(); + $response->assertExactJson([ + 'status' => 1, + 'description' => "The $parameter field is required.", + ]); + $this->assertDatabaseMissing('buildfile', ['buildid' => $buildid]); + Queue::assertNothingPushed(); + $this->assertSame([], Storage::allFiles()); + } + + /** + * @return array + */ + public static function invalidBuildIdCases(): array + { + return [ + 'non-numeric' => ['abc'], + 'zero' => ['0'], + 'negative' => ['-1'], + 'fractional' => ['1.5'], + 'exponent' => ['1e3'], + 'too large' => ['99999999999999999999'], + 'nonexistent' => [(string) PHP_INT_MAX], + ]; + } + + #[DataProvider('invalidBuildIdCases')] + public function testRejectsDataFileWithInvalidBuildId(string $buildid): void + { + $response = $this->putDataFile([ + ...$this->dataFileParameters($this->createBuild()), + 'buildid' => $buildid, + ]); + + $response->assertNotFound(); + $response->assertExactJson([ + 'status' => 1, + 'description' => 'Build not found', + ]); + $this->assertDatabaseMissing('buildfile', ['md5' => md5(self::FILE_CONTENTS)]); + Queue::assertNothingPushed(); + $this->assertSame([], Storage::allFiles()); + } + + /** + * @return array + */ + public static function invalidTypeCases(): array + { + return [ + 'unknown type' => ['NotAType'], + 'wrong case' => ['bazeljson'], + 'path' => ['../../../BazelJSON'], + ]; + } + + #[DataProvider('invalidTypeCases')] + public function testRejectsDataFileWithInvalidType(string $type): void + { + $buildid = $this->createBuild(); + + $response = $this->putDataFile([ + ...$this->dataFileParameters($buildid), + 'type' => $type, + ]); + + $response->assertBadRequest(); + $response->assertExactJson([ + 'status' => 1, + 'description' => 'The selected type is invalid.', + ]); + $this->assertDatabaseMissing('buildfile', ['buildid' => $buildid]); + Queue::assertNothingPushed(); + $this->assertSame([], Storage::allFiles()); + } + + /** + * @return array + */ + public static function improperlyFormattedMd5Cases(): array + { + return [ + 'not hexadecimal' => ['bad_md5sum'], + 'too short' => ['ffffffffffffffffffffffffffffff'], + 'too long' => ['ffffffffffffffffffffffffffffffffff'], + 'path' => ['../../../ffffffffffffffffffffffff'], + ]; + } + + #[DataProvider('improperlyFormattedMd5Cases')] + public function testRejectsDataFileWithImproperlyFormattedMd5(string $md5): void + { + $buildid = $this->createBuild(); + + $response = $this->putDataFile([ + ...$this->dataFileParameters($buildid), + 'md5' => $md5, + ]); + + $response->assertBadRequest(); + $response->assertExactJson([ + 'status' => 1, + 'description' => 'The md5 format is invalid.', + ]); + $this->assertDatabaseMissing('buildfile', ['buildid' => $buildid]); + Queue::assertNothingPushed(); + $this->assertSame([], Storage::allFiles()); + } + + public function testRejectsDataFileWithMismatchedMd5(): void + { + $buildid = $this->createBuild(); + + $response = $this->putDataFile([ + ...$this->dataFileParameters($buildid), + 'md5' => 'ffffffffffffffffffffffffffffffff', + ]); + + $response->assertBadRequest(); + $response->assertExactJson([ + 'status' => 1, + 'description' => 'md5 mismatch. expected: ffffffffffffffffffffffffffffffff, received: ' . md5(self::FILE_CONTENTS), + ]); + $this->assertDatabaseMissing('buildfile', ['buildid' => $buildid]); + $this->assertDatabaseMissing('pending_submissions', ['buildid' => $buildid]); + Queue::assertNothingPushed(); + $this->assertSame([], Storage::allFiles()); + } + + public function testAcceptsDataFile(): void + { + $buildid = $this->createBuild(); + $md5 = md5(self::FILE_CONTENTS); + + $response = $this->putDataFile($this->dataFileParameters($buildid)); + + $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(); + $buildid = $this->createBuild(); + + $response = $this->putDataFile($this->dataFileParameters($buildid)); + + $response->assertForbidden(); + $response->assertExactJson([ + 'status' => 1, + 'description' => 'Forbidden', + ]); + $this->assertDatabaseMissing('buildfile', ['buildid' => $buildid]); + Queue::assertNothingPushed(); + $this->assertSame([], Storage::allFiles()); + } + + public function testRejectsDataFileWithUnknownTokenWhenProjectRequiresAuthentication(): void + { + $this->requireAuthenticatedSubmissions(); + $buildid = $this->createBuild(); + + $response = $this->putDataFile($this->dataFileParameters($buildid), Str::uuid()->toString()); + + $response->assertForbidden(); + $response->assertExactJson([ + 'status' => 1, + 'description' => 'Forbidden', + ]); + $this->assertDatabaseMissing('buildfile', ['buildid' => $buildid]); + Queue::assertNothingPushed(); + $this->assertSame([], Storage::allFiles()); + } + + public function testAcceptsDataFileWithValidTokenWhenProjectRequiresAuthentication(): void + { + $this->requireAuthenticatedSubmissions(); + $buildid = $this->createBuild(); + $token = $this->createSubmitOnlyToken(); + + $response = $this->putDataFile($this->dataFileParameters($buildid), $token); + + $response->assertOk(); + $response->assertExactJson(['status' => 0]); + $this->assertDatabaseHas('buildfile', ['buildid' => $buildid]); + Queue::assertCount(1); + Queue::assertPushed(ProcessSubmission::class, fn (ProcessSubmission $job) => $job->projectid === $this->project->id + && $job->buildid === $buildid + && str_contains($job->filename, '_-_' . AuthTokenService::hash($token) . '_-_')); + } + + public function testAcceptsDataFileWithUnknownTokenWhenProjectDoesNotRequireAuthentication(): void + { + $buildid = $this->createBuild(); + + $response = $this->putDataFile($this->dataFileParameters($buildid), Str::uuid()->toString()); + + $response->assertOk(); + $response->assertExactJson(['status' => 0]); + $this->assertDatabaseHas('buildfile', ['buildid' => $buildid]); + Queue::assertCount(1); + Queue::assertPushed(ProcessSubmission::class, fn (ProcessSubmission $job) => $job->projectid === $this->project->id + && $job->buildid === $buildid); + } +}