Skip to content

Commit e226fff

Browse files
committed
Tightening CallError validation by adding structure checks and normalizing input formats
1 parent 9da4985 commit e226fff

2 files changed

Lines changed: 129 additions & 7 deletions

File tree

src/Ocpp/JsonSchemaValidator.php

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,17 @@ class JsonSchemaValidator
2222
public static function validate(Call|CallResult|CallError|array $message, string $version, bool $throwException = true): bool|CallError
2323
{
2424
// CallError messages represent protocol-level error responses (e.g., NotImplemented).
25-
// They have no payload to validate — the error code itself IS the message.
25+
// They have no JSON schema to validate against — instead we verify they parse
26+
// into a well-formed CallError with a recognized error structure.
2627
if ($message instanceof CallError) {
27-
return true;
28+
return self::validateCallErrorStructure($message, $throwException);
2829
}
2930

30-
// Raw array with messageTypeID=4 is also a CallError — skip validation.
31+
// Raw array with messageTypeID=4 is a CallError. Parse it first so we validate
32+
// its structure rather than blindly returning true.
3133
if (is_array($message) && ($message['messageTypeID'] ?? $message[0] ?? null) === 4) {
32-
return true;
34+
$callError = self::normalizeCallErrorArray($message);
35+
return self::validateCallErrorStructure($callError, $throwException);
3336
}
3437

3538
$registry = new JsonSchemaRegistry();
@@ -281,4 +284,79 @@ private static function resolveSchemaReference(string $reference, array $rootSch
281284

282285
return is_array($current) ? $current : [];
283286
}
287+
288+
/**
289+
* Validate that a CallError has a well-formed structure.
290+
*
291+
* CallError messages don't have a JSON schema to validate against, but we
292+
* verify basic structural integrity: a non-empty errorCode, a string
293+
* errorDescription, and an array errorDetails.
294+
*/
295+
private static function validateCallErrorStructure(CallError $callError, bool $throwException): bool|CallError
296+
{
297+
$errors = [];
298+
299+
if (!is_string($callError->errorCode) || $callError->errorCode === '') {
300+
$errors[] = 'The property errorCode is required and must be a non-empty string';
301+
}
302+
303+
if (isset($callError->errorDescription) && !is_string($callError->errorDescription)) {
304+
$errors[] = 'The property errorDescription must be a string';
305+
}
306+
307+
if (!is_array($callError->errorDetails)) {
308+
$errors[] = 'The property errorDetails must be an array';
309+
}
310+
311+
if ($errors !== []) {
312+
$protocolError = new ProtocolError(
313+
$callError->uniqueId,
314+
null,
315+
null,
316+
$errors
317+
);
318+
319+
if ($throwException) {
320+
throw new \Exception(self::formatValidationExceptionMessage($protocolError));
321+
}
322+
323+
return $protocolError;
324+
}
325+
326+
return true;
327+
}
328+
329+
/**
330+
* Normalize a raw CallError array into a CallError object.
331+
*
332+
* Handles both the raw OCPP wire format [4, uniqueId, errorCode, ...]
333+
* and the parsed message format ['messageTypeID' => 4, ...].
334+
*/
335+
private static function normalizeCallErrorArray(array $message): CallError
336+
{
337+
// Raw OCPP wire format: [4, uniqueId, errorCode, errorDescription, errorDetails]
338+
if (isset($message[0]) && $message[0] === 4) {
339+
return CallError::fromArray($message);
340+
}
341+
342+
// Parsed message format from parseJsonMessage:
343+
// ['messageTypeID' => 4, 'uniqueId' => '...', 'action' => '...', 'payload' => [...]]
344+
$uniqueId = $message['uniqueId'] ?? '';
345+
$errorCode = $message['action'] ?? $message['errorCode'] ?? '';
346+
$payload = $message['payload'] ?? [];
347+
348+
// The payload in parsed format may contain errorDescription and errorDetails
349+
$errorDescription = '';
350+
$errorDetails = [];
351+
352+
if (is_array($payload)) {
353+
$errorDescription = $payload['errorDescription'] ?? '';
354+
$errorDetails = $payload['errorDetails'] ?? [];
355+
}
356+
357+
// Rebuild into raw format for CallError::fromArray
358+
$raw = [4, $uniqueId, $errorCode, $errorDescription, $errorDetails];
359+
360+
return CallError::fromArray($raw);
361+
}
284362
}

tests/CallErrorTest.php

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ public function test_to_array_roundtrip_with_details(): void
219219
$this->assertEquals((object) ['detail' => 'extra'], $array[4]);
220220
}
221221

222-
public function test_validator_skips_call_error_objects(): void
222+
public function test_validator_accepts_well_formed_call_error_objects(): void
223223
{
224224
$error = new NotImplementedError('test-id');
225225

@@ -228,7 +228,7 @@ public function test_validator_skips_call_error_objects(): void
228228
$this->assertTrue($result);
229229
}
230230

231-
public function test_validator_skips_call_error_arrays(): void
231+
public function test_validator_accepts_call_error_parsed_format(): void
232232
{
233233
$rawCallError = [
234234
'messageTypeID' => 4,
@@ -242,7 +242,7 @@ public function test_validator_skips_call_error_arrays(): void
242242
$this->assertTrue($result);
243243
}
244244

245-
public function test_validator_skips_call_error_array_with_numeric_keys(): void
245+
public function test_validator_accepts_call_error_wire_format(): void
246246
{
247247
$rawCallError = [4, 'test-id', 'NotImplemented', '', []];
248248

@@ -251,6 +251,50 @@ public function test_validator_skips_call_error_array_with_numeric_keys(): void
251251
$this->assertTrue($result);
252252
}
253253

254+
public function test_validator_rejects_call_error_with_empty_error_code(): void
255+
{
256+
$error = new NotImplementedError('test-id');
257+
// Override the errorCode to be empty — simulates a malformed CallError
258+
$error->errorCode = '';
259+
260+
$result = JsonSchemaValidator::validate($error, 'v1.6', false);
261+
262+
$this->assertInstanceOf(ProtocolError::class, $result);
263+
$this->assertStringContainsString('errorCode', $result->errorDetails[0]);
264+
}
265+
266+
public function test_validator_throws_on_malformed_call_error_when_throw_enabled(): void
267+
{
268+
$error = new NotImplementedError('test-id');
269+
$error->errorCode = '';
270+
271+
$this->expectException(\Exception::class);
272+
$this->expectExceptionMessage('ProtocolError:');
273+
274+
JsonSchemaValidator::validate($error, 'v1.6', true);
275+
}
276+
277+
public function test_validator_rejects_raw_array_type4_with_empty_error_code(): void
278+
{
279+
$raw = [4, 'test-id', '', '', []];
280+
281+
$result = JsonSchemaValidator::validate($raw, 'v1.6', false);
282+
283+
$this->assertInstanceOf(ProtocolError::class, $result);
284+
$this->assertStringContainsString('errorCode', $result->errorDetails[0]);
285+
}
286+
287+
public function test_validator_accepts_unknown_error_codes(): void
288+
{
289+
// Unknown error codes should still be accepted — they map to UnknownCallErrorCodeError
290+
// and are structurally valid (non-empty errorCode string)
291+
$raw = [4, 'test-id', 'SomeCustomError', 'Custom message', []];
292+
293+
$result = JsonSchemaValidator::validate($raw, 'v1.6');
294+
295+
$this->assertTrue($result);
296+
}
297+
254298
public function test_registry_create_from_array_handles_call_error(): void
255299
{
256300
$registry = new JsonSchemaRegistry();

0 commit comments

Comments
 (0)