diff --git a/src/Internal/PresentJsonNull.php b/src/Internal/PresentJsonNull.php new file mode 100644 index 0000000..82a991b --- /dev/null +++ b/src/Internal/PresentJsonNull.php @@ -0,0 +1,32 @@ +getStatusCode(), - $this->extractJsonBody($response, $content, $contentType), + $this->extractJsonBody($content, $contentType), $contentType !== '' ? $contentType : null, // HeaderNormalizer is idempotent; HeaderBag's already-lower-cased // keys pass through unchanged. @@ -1125,8 +1126,13 @@ private function resolveBoolConfig(string $key): bool /** * Extract the request body in the shape OpenApiRequestValidator expects. * Mirrors {@see self::extractJsonBody()} for the request side: parse JSON - * only when the Content-Type claims it, stay `null` on empty or non-JSON - * bodies so the validator decides whether the spec required one. + * only when the Content-Type claims it (or is absent), stay `null` on + * empty or non-JSON bodies so the validator decides whether the spec + * required one. + * + * Issue #246: a non-empty body that decodes to the literal JSON `null` + * yields a {@see PresentJsonNull} marker so the validator type-checks the + * value against the schema instead of mistaking it for an absent body. */ private function extractRequestBody(Request $request, string $contentType): mixed { @@ -1139,21 +1145,37 @@ private function extractRequestBody(Request $request, string $contentType): mixe return null; } + // The return lives inside the try so its dependence on a successful + // decode is local and explicit: failOpenApi() is `: never`, so the + // catch cannot fall through to a use of an undefined $decoded. try { /** @var mixed $decoded */ $decoded = json_decode($content, true, flags: JSON_THROW_ON_ERROR); + + return $decoded ?? PresentJsonNull::Body; } catch (JsonException $e) { $this->failOpenApi( 'Request body could not be parsed as JSON: ' . $e->getMessage() . ($contentType === '' ? ' (no Content-Type header was present on the request)' : ''), ); } - - return $decoded; } - /** @return null|array */ - private function extractJsonBody(TestResponse $response, string $content, string $contentType): ?array + /** + * Extract the response body in the shape OpenApiResponseValidator expects. + * Mirrors {@see self::extractRequestBody()}: parse JSON only when the + * Content-Type claims it (or is absent), stay `null` on empty or non-JSON + * bodies so the validator decides whether the spec required one. + * + * Issue #246: decoding goes through `json_decode()` rather than + * `TestResponse::json()` so a body of the literal JSON `null` is not + * tripped up by Laravel's "null decode == invalid JSON" heuristic, and a + * scalar body is returned as-is for schema type-checking instead of being + * forced into an array. A present literal `null` yields a + * {@see PresentJsonNull} marker so it is type-checked rather than read as + * an absent body — keeping this adapter aligned with the Symfony one. + */ + private function extractJsonBody(string $content, string $contentType): mixed { if ($content === '') { return null; @@ -1165,8 +1187,13 @@ private function extractJsonBody(TestResponse $response, string $content, string return null; } + // See extractRequestBody(): the return is inside the try so its + // dependence on a successful decode is local and explicit. try { - return $response->json(); + /** @var mixed $decoded */ + $decoded = json_decode($content, true, flags: JSON_THROW_ON_ERROR); + + return $decoded ?? PresentJsonNull::Body; } catch (JsonException $e) { $this->failOpenApi( 'Response body could not be parsed as JSON: ' . $e->getMessage() diff --git a/src/OpenApiResponseValidator.php b/src/OpenApiResponseValidator.php index ea3ea60..96c5179 100644 --- a/src/OpenApiResponseValidator.php +++ b/src/OpenApiResponseValidator.php @@ -6,6 +6,7 @@ use InvalidArgumentException; use RuntimeException; +use Studio\OpenApiContractTesting\Internal\PresentJsonNull; use Studio\OpenApiContractTesting\PHPUnit\OpenApiCoverageExtension; use Studio\OpenApiContractTesting\Spec\OpenApiPathMatcher; use Studio\OpenApiContractTesting\Spec\OpenApiSpecLoader; @@ -235,7 +236,10 @@ public function validate( $matchedPath, $statusCodeStr, $bodyResult->matchedContentType, - $responseBody, + // Issue #246: unwrap the present-literal-null marker so the + // strict-required walker observes the real `null` value + // rather than the marker object. + $responseBody instanceof PresentJsonNull ? null : $responseBody, ); return OpenApiValidationResult::success( diff --git a/src/Symfony/OpenApiAssertions.php b/src/Symfony/OpenApiAssertions.php index 2be00cd..349d615 100644 --- a/src/Symfony/OpenApiAssertions.php +++ b/src/Symfony/OpenApiAssertions.php @@ -11,6 +11,7 @@ use PHPUnit\Framework\AssertionFailedError; use Studio\OpenApiContractTesting\Coverage\OpenApiCoverageTracker; use Studio\OpenApiContractTesting\HttpMethod; +use Studio\OpenApiContractTesting\Internal\PresentJsonNull; use Studio\OpenApiContractTesting\Internal\StackTraceFilter; use Studio\OpenApiContractTesting\OpenApiRequestValidator; use Studio\OpenApiContractTesting\OpenApiResponseValidator; @@ -329,8 +330,14 @@ private function symfonyRequestValidator(): OpenApiRequestValidator /** * Decode a JSON request / response body in the shape the validators * expect. Mirrors the Laravel adapter: parse only when the Content-Type - * claims JSON, stay `null` on empty or non-JSON bodies so the validator - * decides whether the spec required one. + * claims JSON (or is absent), stay `null` on empty or non-JSON bodies so + * the validator decides whether the spec required one. + * + * Issue #246: when the raw content is non-empty but decodes to the literal + * JSON `null`, a {@see PresentJsonNull} marker is returned instead of a + * bare `null` so the validator type-checks the value against the schema + * rather than mistaking it for an absent body. Non-null decoded values + * (scalars, arrays) pass through unchanged. * * @param string $subject either `Request` or `Response`, used only for the * error message when the body is not valid JSON @@ -345,9 +352,14 @@ private function extractSymfonyJsonBody(string $content, string $contentType, st return null; } + // The return is inside the try so its dependence on a successful + // decode is local and explicit: failOpenApi() is `: never`, so the + // catch cannot fall through to a use of an undefined $decoded. try { /** @var mixed $decoded */ $decoded = json_decode($content, true, flags: JSON_THROW_ON_ERROR); + + return $decoded ?? PresentJsonNull::Body; } catch (JsonException $e) { $this->failOpenApi(sprintf( '%s body could not be parsed as JSON: %s%s', @@ -358,8 +370,6 @@ private function extractSymfonyJsonBody(string $content, string $contentType, st : '', )); } - - return $decoded; } /** Like Assert::fail() but with vendor frames stripped from the trace. */ diff --git a/src/Validation/Request/RequestBodyValidator.php b/src/Validation/Request/RequestBodyValidator.php index c366ff0..f747d7f 100644 --- a/src/Validation/Request/RequestBodyValidator.php +++ b/src/Validation/Request/RequestBodyValidator.php @@ -5,6 +5,7 @@ namespace Studio\OpenApiContractTesting\Validation\Request; use stdClass; +use Studio\OpenApiContractTesting\Internal\PresentJsonNull; use Studio\OpenApiContractTesting\OpenApiVersion; use Studio\OpenApiContractTesting\SchemaContext; use Studio\OpenApiContractTesting\Spec\OpenApiSchemaConverter; @@ -141,7 +142,17 @@ public function validate( return []; } - if ($requestBody === null) { + // Issue #246: see the matching comment in ResponseBodyValidator. A + // PresentJsonNull marker means the wire carried a literal JSON `null` + // body; unwrap it and flag the body as present so it is type-checked + // against the schema instead of taking the empty-body branch. + $bodyWasPresent = false; + if ($requestBody instanceof PresentJsonNull) { + $requestBody = null; + $bodyWasPresent = true; + } + + if ($requestBody === null && !$bodyWasPresent) { if (!$required) { return []; } diff --git a/src/Validation/Response/ResponseBodyValidator.php b/src/Validation/Response/ResponseBodyValidator.php index 2c0a43c..319d333 100644 --- a/src/Validation/Response/ResponseBodyValidator.php +++ b/src/Validation/Response/ResponseBodyValidator.php @@ -5,6 +5,7 @@ namespace Studio\OpenApiContractTesting\Validation\Response; use stdClass; +use Studio\OpenApiContractTesting\Internal\PresentJsonNull; use Studio\OpenApiContractTesting\OpenApiResponseValidator; use Studio\OpenApiContractTesting\OpenApiValidationResult; use Studio\OpenApiContractTesting\OpenApiVersion; @@ -106,7 +107,18 @@ public function validate( return new ResponseBodyValidationResult([], $jsonContentType); } - if ($responseBody === null) { + // Issue #246: an adapter that saw non-empty raw content but decoded a + // literal JSON `null` passes the PresentJsonNull marker so the null is + // type-checked against the schema below, rather than short-circuiting + // as an absent body. Unwrap it to a real null and remember the body + // WAS present so the empty-body branch is bypassed. + $bodyWasPresent = false; + if ($responseBody instanceof PresentJsonNull) { + $responseBody = null; + $bodyWasPresent = true; + } + + if ($responseBody === null && !$bodyWasPresent) { return new ResponseBodyValidationResult( [ "Response body is empty but {$method} {$matchedPath} (status {$statusCode}) defines a JSON-compatible response schema in '{$specName}' spec.", diff --git a/tests/Unit/Symfony/OpenApiAssertionsTest.php b/tests/Unit/Symfony/OpenApiAssertionsTest.php index b811c58..63ab497 100644 --- a/tests/Unit/Symfony/OpenApiAssertionsTest.php +++ b/tests/Unit/Symfony/OpenApiAssertionsTest.php @@ -190,6 +190,56 @@ public function malformed_json_request_body_without_content_type_adds_hint(): vo $this->assertRequestMatchesOpenApiSchema($request); } + #[Test] + public function literal_null_response_body_without_content_type_fails_loudly(): void + { + // Issue #246: a response body of the literal JSON `null` with no + // Content-Type is type-checked against the schema, not silently read + // as an absent body. GET /v1/pets declares a `type: object` 200 + // schema, so a null body is a contract violation surfaced as a schema + // type error rather than the misleading "Response body is empty". + $request = Request::create('/v1/pets', 'GET'); + $response = new Response('null', 200); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('must match the type'); + + $this->assertResponseMatchesOpenApiSchema($request, $response); + } + + #[Test] + public function scalar_response_body_without_content_type_is_type_checked(): void + { + // Issue #246: a scalar JSON body (here the integer `123`) is decoded + // and type-checked against the schema rather than being treated as no + // body. Regression guard — the Symfony adapter's `mixed` body shape + // already handled scalars; this pins that the #246 fix keeps it so. + $request = Request::create('/v1/pets', 'GET'); + $response = new Response('123', 200); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('must match the type'); + + $this->assertResponseMatchesOpenApiSchema($request, $response); + } + + #[Test] + public function literal_null_request_body_without_content_type_fails_loudly(): void + { + // Issue #246: a request body of the literal JSON `null` with no + // Content-Type is type-checked against the requestBody schema. POST + // /v1/pets requires a `type: object` body, so a null body fails + // loudly. Request::create() forces a Content-Type on POST bodies; + // drop it so the no-Content-Type path runs. + $request = Request::create('/v1/pets', 'POST', [], [], [], [], 'null'); + $request->headers->remove('Content-Type'); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('must match the type'); + + $this->assertRequestMatchesOpenApiSchema($request); + } + #[Test] public function non_json_response_body_passes_as_null_body(): void { diff --git a/tests/Unit/ValidatesOpenApiSchemaAutoValidateRequestTest.php b/tests/Unit/ValidatesOpenApiSchemaAutoValidateRequestTest.php index d606187..b2cd227 100644 --- a/tests/Unit/ValidatesOpenApiSchemaAutoValidateRequestTest.php +++ b/tests/Unit/ValidatesOpenApiSchemaAutoValidateRequestTest.php @@ -127,6 +127,26 @@ public function auto_validate_request_true_raises_on_invalid_body(): void $this->maybeAutoValidateOpenApiRequest($request, HttpMethod::POST, '/v1/pets'); } + #[Test] + public function auto_validate_request_type_checks_literal_null_body(): void + { + // Issue #246: a request body of the literal JSON `null` with no + // Content-Type is type-checked against the requestBody schema instead + // of being read as an absent body. POST /v1/pets requires a + // `type: object` body, so a null body fails loudly with a schema type + // error — before the fix the decoded `null` was misreported as an + // empty body. + $GLOBALS['__openapi_testing_config']['openapi-contract-testing.auto_validate_request'] = true; + + $request = Request::create('/v1/pets', 'POST', [], [], [], [], 'null'); + $request->headers->remove('Content-Type'); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('must match the type'); + + $this->maybeAutoValidateOpenApiRequest($request, HttpMethod::POST, '/v1/pets'); + } + #[Test] public function auto_validate_request_true_raises_on_missing_bearer(): void { diff --git a/tests/Unit/ValidatesOpenApiSchemaTest.php b/tests/Unit/ValidatesOpenApiSchemaTest.php index 1d01bce..5e17394 100644 --- a/tests/Unit/ValidatesOpenApiSchemaTest.php +++ b/tests/Unit/ValidatesOpenApiSchemaTest.php @@ -144,6 +144,65 @@ public function successful_validation_records_coverage(): void $this->assertArrayHasKey('GET /v1/pets', $covered['petstore-3.0']); } + #[Test] + public function literal_null_response_body_is_type_checked(): void + { + // Issue #246: a response body of the literal JSON `null` is + // type-checked against the schema instead of being read as an absent + // body. GET /v1/pets declares a `type: object` 200 schema, so a null + // body fails with a schema type error. Before the fix the Laravel + // adapter decoded through TestResponse::json(), whose "null decode == + // invalid JSON" heuristic raised a misleading framework failure. + $response = $this->makeTestResponse('null', 200); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('must match the type'); + + $this->assertResponseMatchesOpenApiSchema( + $response, + HttpMethod::GET, + '/v1/pets', + ); + } + + #[Test] + public function literal_null_response_body_with_json_content_type_is_type_checked(): void + { + // Issue #246: the literal-null fix applies on the explicit-Content-Type + // path too, not only when the header is absent. A response with + // `Content-Type: application/json` and a `null` body is type-checked + // against GET /v1/pets' `type: object` 200 schema and fails. + $response = $this->makeTestResponse('null', 200, ['Content-Type' => 'application/json']); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('must match the type'); + + $this->assertResponseMatchesOpenApiSchema( + $response, + HttpMethod::GET, + '/v1/pets', + ); + } + + #[Test] + public function scalar_response_body_is_type_checked(): void + { + // Issue #246: a scalar JSON body (the integer `123`) reaches the + // validator and is type-checked. Before the fix the body extractor's + // `?array` return type raised a TypeError on a non-array decoded + // body; the Laravel and Symfony adapters now behave identically. + $response = $this->makeTestResponse('123', 200); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('must match the type'); + + $this->assertResponseMatchesOpenApiSchema( + $response, + HttpMethod::GET, + '/v1/pets', + ); + } + #[Test] public function non_json_html_body_passes_as_null_body(): void { diff --git a/tests/Unit/Validation/Request/RequestBodyValidatorTest.php b/tests/Unit/Validation/Request/RequestBodyValidatorTest.php index 4e167b4..a7877d5 100644 --- a/tests/Unit/Validation/Request/RequestBodyValidatorTest.php +++ b/tests/Unit/Validation/Request/RequestBodyValidatorTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use Studio\OpenApiContractTesting\Internal\PresentJsonNull; use Studio\OpenApiContractTesting\OpenApiVersion; use Studio\OpenApiContractTesting\Validation\Request\RequestBodyValidator; use Studio\OpenApiContractTesting\Validation\Support\SchemaValidatorRunner; @@ -62,6 +63,153 @@ public function validate_flags_missing_required_body(): void $this->assertStringContainsString('Request body is empty', $errors[0]); } + #[Test] + public function validate_flags_present_literal_null_body_against_object_schema_when_optional(): void + { + // Issue #246 — the core silent-pass bug. A request body of the literal + // JSON `null` against an OPTIONAL `type: object` body must NOT pass: + // before the fix the validator read the decoded `null` as "no body" + // and, because the body was optional, returned no errors — letting a + // malformed `null` body slip through unchecked. A present `null` is + // now type-checked against the schema and fails loudly. + $operation = [ + 'requestBody' => [ + 'required' => false, + 'content' => [ + 'application/json' => ['schema' => ['type' => 'object']], + ], + ], + ]; + + $errors = $this->validator->validate( + 'spec', + 'POST', + '/pets', + $operation, + PresentJsonNull::Body, + 'application/json', + OpenApiVersion::V3_0, + ); + + $this->assertNotEmpty($errors); + $this->assertStringContainsString('must match the type', $errors[0]); + } + + #[Test] + public function validate_flags_present_literal_null_body_against_object_schema_when_required(): void + { + // A present literal `null` against a REQUIRED object body fails with a + // schema type error, not the "Request body is empty" message — the + // body WAS present on the wire, it is simply the wrong type. + $operation = [ + 'requestBody' => [ + 'required' => true, + 'content' => [ + 'application/json' => ['schema' => ['type' => 'object']], + ], + ], + ]; + + $errors = $this->validator->validate( + 'spec', + 'POST', + '/pets', + $operation, + PresentJsonNull::Body, + 'application/json', + OpenApiVersion::V3_0, + ); + + $this->assertNotEmpty($errors); + $this->assertStringContainsString('must match the type', $errors[0]); + $this->assertStringNotContainsString('Request body is empty', $errors[0]); + } + + #[Test] + public function validate_accepts_present_literal_null_body_against_oas_31_nullable_schema(): void + { + // OAS 3.1 `type: ["object", "null"]` explicitly permits a null body. + $operation = [ + 'requestBody' => [ + 'required' => true, + 'content' => [ + 'application/json' => ['schema' => ['type' => ['object', 'null']]], + ], + ], + ]; + + $errors = $this->validator->validate( + 'spec', + 'POST', + '/pets', + $operation, + PresentJsonNull::Body, + 'application/json', + OpenApiVersion::V3_1, + ); + + $this->assertSame([], $errors); + } + + #[Test] + public function validate_accepts_present_literal_null_body_against_oas_30_nullable_schema(): void + { + // OAS 3.0 expresses a nullable body with `nullable: true`; + // OpenApiSchemaConverter lowers it to a `["object", "null"]` type + // array for Draft 07. A present literal `null` validates cleanly + // against it — distinct conversion branch from the OAS 3.1 type-array + // form covered above. + $operation = [ + 'requestBody' => [ + 'required' => true, + 'content' => [ + 'application/json' => ['schema' => ['type' => 'object', 'nullable' => true]], + ], + ], + ]; + + $errors = $this->validator->validate( + 'spec', + 'POST', + '/pets', + $operation, + PresentJsonNull::Body, + 'application/json', + OpenApiVersion::V3_0, + ); + + $this->assertSame([], $errors); + } + + #[Test] + public function validate_still_treats_plain_null_as_absent_body(): void + { + // Regression guard for issue #246: a plain PHP `null` (absent body, + // raw content was empty) keeps the historical "no body" semantics — + // it is NOT type-checked. An optional absent body still passes; the + // PresentJsonNull marker is the ONLY value whose handling changed. + $operation = [ + 'requestBody' => [ + 'required' => false, + 'content' => [ + 'application/json' => ['schema' => ['type' => 'object']], + ], + ], + ]; + + $errors = $this->validator->validate( + 'spec', + 'POST', + '/pets', + $operation, + null, + 'application/json', + OpenApiVersion::V3_0, + ); + + $this->assertSame([], $errors); + } + #[Test] public function validate_flags_unknown_non_json_content_type(): void { diff --git a/tests/Unit/Validation/Response/ResponseBodyValidatorTest.php b/tests/Unit/Validation/Response/ResponseBodyValidatorTest.php index 551c833..0cb54df 100644 --- a/tests/Unit/Validation/Response/ResponseBodyValidatorTest.php +++ b/tests/Unit/Validation/Response/ResponseBodyValidatorTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use Studio\OpenApiContractTesting\Internal\PresentJsonNull; use Studio\OpenApiContractTesting\OpenApiVersion; use Studio\OpenApiContractTesting\Validation\Response\ResponseBodyValidator; use Studio\OpenApiContractTesting\Validation\Support\SchemaValidatorRunner; @@ -71,6 +72,89 @@ public function validate_flags_empty_body_against_json_schema(): void $this->assertSame('application/json', $result->matchedContentType); } + #[Test] + public function validate_type_checks_present_literal_null_body_against_object_schema(): void + { + // Issue #246: a response body of the literal JSON `null` (the four + // bytes `null` on the wire) is type-checked against the schema, not + // short-circuited as an absent body. Against `type: object` it is a + // contract violation and must surface a schema type error — NOT the + // "Response body is empty" message reserved for a genuinely absent + // body. The PresentJsonNull marker is how an adapter signals "the + // wire carried a body and its decoded value is null". + $content = [ + 'application/json' => ['schema' => ['type' => 'object']], + ]; + + $result = $this->validator->validate( + 'spec', + 'GET', + '/pets', + 200, + $content, + PresentJsonNull::Body, + 'application/json', + OpenApiVersion::V3_0, + ); + + $this->assertNotEmpty($result->errors); + $this->assertStringContainsString('must match the type', $result->errors[0]); + $this->assertStringNotContainsString('Response body is empty', $result->errors[0]); + $this->assertSame('application/json', $result->matchedContentType); + } + + #[Test] + public function validate_accepts_present_literal_null_body_against_oas_31_nullable_schema(): void + { + // OAS 3.1 `type: ["object", "null"]` explicitly permits a null body. + // A present literal `null` validates cleanly against it — the pre-#246 + // "body is empty" short-circuit would have wrongly rejected it. + $content = [ + 'application/json' => ['schema' => ['type' => ['object', 'null']]], + ]; + + $result = $this->validator->validate( + 'spec', + 'GET', + '/pets', + 200, + $content, + PresentJsonNull::Body, + 'application/json', + OpenApiVersion::V3_1, + ); + + $this->assertSame([], $result->errors); + $this->assertSame('application/json', $result->matchedContentType); + } + + #[Test] + public function validate_accepts_present_literal_null_body_against_oas_30_nullable_schema(): void + { + // OAS 3.0 expresses a nullable body with `nullable: true`; + // OpenApiSchemaConverter lowers it to a `["object", "null"]` type + // array for Draft 07. A present literal `null` must validate cleanly + // against it — distinct conversion branch from the OAS 3.1 type-array + // form covered above. + $content = [ + 'application/json' => ['schema' => ['type' => 'object', 'nullable' => true]], + ]; + + $result = $this->validator->validate( + 'spec', + 'GET', + '/pets', + 200, + $content, + PresentJsonNull::Body, + 'application/json', + OpenApiVersion::V3_0, + ); + + $this->assertSame([], $result->errors); + $this->assertSame('application/json', $result->matchedContentType); + } + #[Test] public function validate_accepts_non_json_content_type_when_defined_in_spec(): void {