Skip to content

Commit ef2a18b

Browse files
authored
chore: refactor error-handling and signatures (#105)
1 parent b4ef2f4 commit ef2a18b

12 files changed

Lines changed: 250 additions & 196 deletions

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
"ext-curl": "*",
2929
"ext-openssl": "*",
3030
"ext-sodium": "*",
31-
"testcontainers/testcontainers": "1.0.8"
31+
"testcontainers/testcontainers": "^1.0.8"
3232
},
3333
"require-dev": {
3434
"phpstan/phpstan": "2.1.51",
@@ -60,6 +60,7 @@
6060
"allow-plugins": {
6161
"php-http/discovery": false
6262
},
63+
"bump-after-update": true,
6364
"sort-packages": true
6465
}
6566
}

composer.lock

Lines changed: 78 additions & 79 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Client.php

Lines changed: 47 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use RuntimeException;
99
use Thenativeweb\Eventsourcingdb\Stream\HttpClient;
1010
use Thenativeweb\Eventsourcingdb\Stream\NdJson;
11+
use Thenativeweb\Eventsourcingdb\Stream\Response;
1112

1213
final readonly class Client
1314
{
@@ -30,17 +31,9 @@ public function abortIn(float $seconds): void
3031
public function ping(): void
3132
{
3233
$response = $this->httpClient->get('/api/v1/ping');
33-
if (!$this->isValidServerHeader($response)) {
34-
throw new RuntimeException('Server must be EventSourcingDB.');
35-
}
36-
$status = $response->getStatusCode();
3734

38-
if ($status !== 200) {
39-
throw new RuntimeException(sprintf(
40-
"Failed to ping, got HTTP status code '%d', expected '200'",
41-
$status
42-
));
43-
}
35+
$this->throwIfNotValidServerHeader($response);
36+
$this->throwIfNotSuccessStatusCode($response, 'Failed to ping');
4437

4538
try {
4639
$data = $response->getStream()->getJsonData();
@@ -63,17 +56,9 @@ public function verifyApiToken(): void
6356
'/api/v1/verify-api-token',
6457
$this->apiToken,
6558
);
66-
if (!$this->isValidServerHeader($response)) {
67-
throw new RuntimeException('Server must be EventSourcingDB.');
68-
}
69-
$status = $response->getStatusCode();
7059

71-
if ($status !== 200) {
72-
throw new RuntimeException(sprintf(
73-
"Failed to verify API token, got HTTP status code '%d', expected '200'",
74-
$status
75-
));
76-
}
60+
$this->throwIfNotValidServerHeader($response);
61+
$this->throwIfNotSuccessStatusCode($response, 'Failed to verify API token');
7762

7863
try {
7964
$data = $response->getStream()->getJsonData();
@@ -104,17 +89,9 @@ public function writeEvents(array $events, array $preconditions = []): array
10489
$this->apiToken,
10590
$requestBody,
10691
);
107-
if (!$this->isValidServerHeader($response)) {
108-
throw new RuntimeException('Server must be EventSourcingDB.');
109-
}
110-
$status = $response->getStatusCode();
11192

112-
if ($status !== 200) {
113-
throw new RuntimeException(sprintf(
114-
"Failed to write events, got HTTP status code '%d', expected '200'",
115-
$status
116-
));
117-
}
93+
$this->throwIfNotValidServerHeader($response);
94+
$this->throwIfNotSuccessStatusCode($response, 'Failed to write events');
11895

11996
try {
12097
$data = $response->getStream()->getJsonData();
@@ -159,17 +136,9 @@ public function readEvents(string $subject, ReadEventsOptions $readEventsOptions
159136
'options' => $readEventsOptions,
160137
],
161138
);
162-
if (!$this->isValidServerHeader($response)) {
163-
throw new RuntimeException('Server must be EventSourcingDB.');
164-
}
165-
$status = $response->getStatusCode();
166139

167-
if ($status !== 200) {
168-
throw new RuntimeException(sprintf(
169-
"Failed to read events, got HTTP status code '%d', expected '200'",
170-
$status
171-
));
172-
}
140+
$this->throwIfNotValidServerHeader($response);
141+
$this->throwIfNotSuccessStatusCode($response, 'Failed to read events');
173142

174143
foreach (NdJson::readStream($response->getStream()) as $eventLine) {
175144
switch ($eventLine->type) {
@@ -210,17 +179,9 @@ public function runEventQlQuery(string $query): iterable
210179
'query' => $query,
211180
],
212181
);
213-
if (!$this->isValidServerHeader($response)) {
214-
throw new RuntimeException('Server must be EventSourcingDB.');
215-
}
216-
$status = $response->getStatusCode();
217182

218-
if ($status !== 200) {
219-
throw new RuntimeException(sprintf(
220-
"Failed to run EventQL query, got HTTP status code '%d', expected '200'",
221-
$status
222-
));
223-
}
183+
$this->throwIfNotValidServerHeader($response);
184+
$this->throwIfNotSuccessStatusCode($response, 'Failed to run EventQL query');
224185

225186
foreach (NdJson::readStream($response->getStream()) as $eventLine) {
226187
switch ($eventLine->type) {
@@ -249,16 +210,9 @@ public function observeEvents(string $subject, ObserveEventsOptions $observeEven
249210
'options' => $observeEventsOptions,
250211
],
251212
);
252-
if (!$this->isValidServerHeader($response)) {
253-
throw new RuntimeException('Server must be EventSourcingDB.');
254-
}
255-
$status = $response->getStatusCode();
256-
if ($status !== 200) {
257-
throw new RuntimeException(sprintf(
258-
"Failed to observe events, got HTTP status code '%d', expected '200'",
259-
$status
260-
));
261-
}
213+
214+
$this->throwIfNotValidServerHeader($response);
215+
$this->throwIfNotSuccessStatusCode($response, 'Failed to observe events');
262216

263217
foreach (NdJson::readStream($response->getStream()) as $eventLine) {
264218
switch ($eventLine->type) {
@@ -302,16 +256,9 @@ public function registerEventSchema(string $eventType, array $schema): void
302256
'schema' => $schema,
303257
],
304258
);
305-
if (!$this->isValidServerHeader($response)) {
306-
throw new RuntimeException('Server must be EventSourcingDB.');
307-
}
308-
$status = $response->getStatusCode();
309-
if ($status !== 200) {
310-
throw new RuntimeException(sprintf(
311-
"Failed to register event schema, got HTTP status code '%d', expected '200'",
312-
$status
313-
));
314-
}
259+
260+
$this->throwIfNotValidServerHeader($response);
261+
$this->throwIfNotSuccessStatusCode($response, 'Failed to register event schema');
315262
}
316263

317264
public function readSubjects(string $baseSubject): iterable
@@ -323,16 +270,9 @@ public function readSubjects(string $baseSubject): iterable
323270
'baseSubject' => $baseSubject,
324271
],
325272
);
326-
if (!$this->isValidServerHeader($response)) {
327-
throw new RuntimeException('Server must be EventSourcingDB.');
328-
}
329-
$status = $response->getStatusCode();
330-
if ($status !== 200) {
331-
throw new RuntimeException(sprintf(
332-
"Failed to read subjects, got HTTP status code '%d', expected '200'",
333-
$status
334-
));
335-
}
273+
274+
$this->throwIfNotValidServerHeader($response);
275+
$this->throwIfNotSuccessStatusCode($response, 'Failed to read subjects');
336276

337277
foreach (NdJson::readStream($response->getStream()) as $eventLine) {
338278
switch ($eventLine->type) {
@@ -357,16 +297,9 @@ public function readEventTypes(): iterable
357297
'/api/v1/read-event-types',
358298
$this->apiToken,
359299
);
360-
if (!$this->isValidServerHeader($response)) {
361-
throw new RuntimeException('Server must be EventSourcingDB.');
362-
}
363-
$status = $response->getStatusCode();
364-
if ($status !== 200) {
365-
throw new RuntimeException(sprintf(
366-
"Failed to read event types, got HTTP status code '%d', expected '200'",
367-
$status
368-
));
369-
}
300+
301+
$this->throwIfNotValidServerHeader($response);
302+
$this->throwIfNotSuccessStatusCode($response, 'Failed to read event types');
370303

371304
foreach (NdJson::readStream($response->getStream()) as $eventLine) {
372305
switch ($eventLine->type) {
@@ -398,16 +331,9 @@ public function readEventType(string $eventType): EventType
398331
'eventType' => $eventType,
399332
],
400333
);
401-
if (!$this->isValidServerHeader($response)) {
402-
throw new RuntimeException('Server must be EventSourcingDB.');
403-
}
404-
$status = $response->getStatusCode();
405-
if ($status !== 200) {
406-
throw new RuntimeException(sprintf(
407-
"Failed to read event type, got HTTP status code '%d', expected '200'",
408-
$status
409-
));
410-
}
334+
335+
$this->throwIfNotValidServerHeader($response);
336+
$this->throwIfNotSuccessStatusCode($response, 'Failed to read event type');
411337

412338
try {
413339
$data = $response->getStream()->getJsonData();
@@ -426,13 +352,31 @@ public function readEventType(string $eventType): EventType
426352
);
427353
}
428354

429-
private function isValidServerHeader(\Thenativeweb\Eventsourcingdb\Stream\Response $response): bool
355+
private function throwIfNotValidServerHeader(Response $response): void
430356
{
431357
$serverHeader = $response->getHeader('Server');
432358

433359
if ($serverHeader === []) {
434-
return false;
360+
throw new RuntimeException('Server Header is empty.');
361+
}
362+
363+
if (!str_starts_with($serverHeader[0], 'EventSourcingDB/')) {
364+
throw new RuntimeException('Server must be EventSourcingDB.');
365+
}
366+
}
367+
368+
private function throwIfNotSuccessStatusCode(Response $response, string $scope): void
369+
{
370+
$status = $response->getStatusCode();
371+
if ($status !== 200) {
372+
throw new RuntimeException(
373+
message: sprintf(
374+
'%s, %s',
375+
$scope,
376+
$response->getStream()->getContents(),
377+
),
378+
code: $status,
379+
);
435380
}
436-
return str_starts_with($serverHeader[0], 'EventSourcingDB/');
437381
}
438382
}

src/CloudEvent.php

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,12 @@ public function verifySignature(string $verificationKey): void
8080
throw new RuntimeException('Failed to decode signature from hex.');
8181
}
8282

83-
if ($signatureBytes === '') {
84-
throw new RuntimeException('Signature must not be empty.');
83+
if (strlen($signatureBytes) !== SODIUM_CRYPTO_SIGN_BYTES) {
84+
throw new RuntimeException(sprintf(
85+
'Signature has an invalid length: expected %d bytes, got %d bytes.',
86+
SODIUM_CRYPTO_SIGN_BYTES,
87+
strlen($signatureBytes),
88+
));
8589
}
8690

8791
$isSignatureValid = sodium_crypto_sign_verify_detached($signatureBytes, $this->hash, $verificationKey);

src/SigningKey.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@
1414

1515
final class SigningKey
1616
{
17-
public string $privateKeyPem;
18-
public string $publicKeyPem;
19-
public Ed25519 $ed25519;
17+
public readonly string $privateKeyPem;
18+
public readonly string $publicKeyPem;
19+
public readonly Ed25519 $ed25519;
2020

2121
public function __construct()
2222
{
@@ -25,7 +25,7 @@ public function __construct()
2525
$secretKey = sodium_crypto_sign_secretkey($keypair);
2626

2727
$privateKey = substr($secretKey, 0, 32);
28-
$publicKey = substr($secretKey, 32, 32);
28+
$publicKey = sodium_crypto_sign_publickey($keypair);
2929

3030
$this->privateKeyPem = $this->generatePem($privateKey, 'PRIVATE KEY');
3131
$this->publicKeyPem = $this->generatePem($publicKey, 'PUBLIC KEY');

tests/CloudEventSignatureTest.php

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,88 @@ protected function tearDown(): void
2626
parent::tearDown();
2727
}
2828

29+
public function testThrowsAnErrorIfVerificationKeyIsEmpty(): void
30+
{
31+
$imageVersion = getImageVersionFromDockerfile();
32+
$container = (new Container())
33+
->withImageTag($imageVersion)
34+
->withSigningKey();
35+
$container->start();
36+
$this->container = $container;
37+
38+
$client = $container->getClient();
39+
40+
$writtenEvents = $client->writeEvents([
41+
new EventCandidate(
42+
source: 'https://www.eventsourcingdb.io',
43+
subject: '/test',
44+
type: 'io.eventsourcingdb.test',
45+
data: [
46+
'value' => 23,
47+
],
48+
),
49+
]);
50+
51+
$this->assertCount(1, $writtenEvents);
52+
$this->assertNotNull($writtenEvents[0]->signature);
53+
54+
$this->expectException(RuntimeException::class);
55+
$this->expectExceptionMessage('Verification key must not be empty.');
56+
57+
$writtenEvents[0]->verifySignature('');
58+
}
59+
60+
public function testThrowsAnErrorIfSignatureHasInvalidLength(): void
61+
{
62+
$imageVersion = getImageVersionFromDockerfile();
63+
$container = (new Container())
64+
->withImageTag($imageVersion)
65+
->withSigningKey();
66+
$container->start();
67+
$this->container = $container;
68+
69+
$client = $container->getClient();
70+
71+
$writtenEvents = $client->writeEvents([
72+
new EventCandidate(
73+
source: 'https://www.eventsourcingdb.io',
74+
subject: '/test',
75+
type: 'io.eventsourcingdb.test',
76+
data: [
77+
'value' => 23,
78+
],
79+
),
80+
]);
81+
82+
$this->assertCount(1, $writtenEvents);
83+
84+
$writtenEvent = $writtenEvents[0];
85+
86+
$this->assertNotNull($writtenEvent->signature);
87+
88+
$tamperedCloudEvent = new CloudEvent(
89+
specVersion: $writtenEvent->specVersion,
90+
id: $writtenEvent->id,
91+
time: $writtenEvent->time,
92+
timeFromServer: $this->getPropertyValue($writtenEvent, 'timeFromServer'),
93+
source: $writtenEvent->source,
94+
subject: $writtenEvent->subject,
95+
type: $writtenEvent->type,
96+
dataContentType: $writtenEvent->dataContentType,
97+
data: $writtenEvent->data,
98+
hash: $writtenEvent->hash,
99+
predecessorHash: $writtenEvent->predecessorHash,
100+
traceParent: $writtenEvent->traceParent,
101+
traceState: $writtenEvent->traceState,
102+
signature: 'esdb:signature:v1:deadbeef',
103+
);
104+
105+
$this->expectException(RuntimeException::class);
106+
$this->expectExceptionMessage('Signature has an invalid length: expected 64 bytes, got 4 bytes.');
107+
108+
$tamperedCloudEvent->verifySignature($container->getVerificationKey());
109+
}
110+
29111
public function testThrowsAnErrorIfTheSignatureIsNull(): void
30112
{
31113
$imageVersion = getImageVersionFromDockerfile();

tests/CloudEventTest.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ public function testThrowsAnErrorIfTheEventHashIsInvalid(): void
7979
);
8080

8181
$this->expectException(RuntimeException::class);
82+
$this->expectExceptionMessage('Failed to verify hash');
83+
8284
$tamperedCloudEvent->verifyHash();
8385
}
8486
}

0 commit comments

Comments
 (0)