diff --git a/src/JetStream/JetStreamContext.php b/src/JetStream/JetStreamContext.php index 7c083d4..0d1222c 100644 --- a/src/JetStream/JetStreamContext.php +++ b/src/JetStream/JetStreamContext.php @@ -42,20 +42,24 @@ public function connection(): Connection // --- Stream Management --- /** + * @throws \InvalidArgumentException If the stream name is invalid * @throws JetStreamException If the stream cannot be created */ public function createStream(StreamConfig $config): StreamInterface { + $this->validateStreamName($config->name); $data = $this->apiRequest('STREAM.CREATE.' . $config->name, $config->toArray()); $info = StreamInfo::fromArray($data); return new Stream($this, $config->name, $info); } /** + * @throws \InvalidArgumentException If the stream name is invalid * @throws JetStreamException If the stream cannot be updated */ public function updateStream(StreamConfig $config): StreamInterface { + $this->validateStreamName($config->name); $data = $this->apiRequest('STREAM.UPDATE.' . $config->name, $config->toArray()); $info = StreamInfo::fromArray($data); return new Stream($this, $config->name, $info); @@ -561,6 +565,25 @@ public function apiRequest(string $operation, ?array $payload = null): array // --- Private helpers --- + /** + * Validates a JetStream stream name per NATS server rules. Stream names become tokens + * in `$JS.API.STREAM.*.` subjects, so they cannot contain anything that would + * split a subject token or fail server-side name validation. Sending an invalid name + * otherwise surfaces as a request timeout, which is hard to diagnose. + */ + private function validateStreamName(string $name): void + { + if ($name === '') { + throw new \InvalidArgumentException('Stream name cannot be empty'); + } + + if (preg_match('/[\s.*>\/\\\\\x00-\x1f\x7f]/', $name)) { + throw new \InvalidArgumentException( + "Invalid stream name '{$name}': cannot contain whitespace, '.', '*', '>', '/', '\\', or control characters", + ); + } + } + private function buildKvStreamConfig(\Sofascore\Nats\KeyValue\KeyValueConfig $config): StreamConfig { $streamName = "KV_{$config->bucket}"; diff --git a/tests/Unit/JetStream/JetStreamContextStreamNameValidationTest.php b/tests/Unit/JetStream/JetStreamContextStreamNameValidationTest.php new file mode 100644 index 0000000..f8ae644 --- /dev/null +++ b/tests/Unit/JetStream/JetStreamContextStreamNameValidationTest.php @@ -0,0 +1,93 @@ +newInstanceWithoutConstructor(); + $method->invoke($context, $name); + } + + #[DataProvider('validNames')] + public function testAcceptsValidStreamName(string $name): void + { + self::invokeValidate($name); + $this->addToAssertionCount(1); + } + + /** + * @return iterable + */ + public static function validNames(): iterable + { + yield 'simple' => ['orders']; + yield 'with-dash' => ['my-stream']; + yield 'with-underscore' => ['KV_cache']; + yield 'alphanumeric' => ['events123']; + yield 'mixed-case' => ['MyStream']; + yield 'object-store-prefix' => ['OBJ_media']; + } + + #[DataProvider('invalidNames')] + public function testRejectsInvalidStreamName(string $name): void + { + $this->expectException(\InvalidArgumentException::class); + self::invokeValidate($name); + } + + /** + * @return iterable + */ + public static function invalidNames(): iterable + { + yield 'empty' => ['']; + yield 'with-space' => ['my stream']; + yield 'with-tab' => ["my\tstream"]; + yield 'with-cr' => ["my\rstream"]; + yield 'with-lf' => ["my\nstream"]; + yield 'with-dot' => ['my.stream']; + yield 'with-star' => ['my*stream']; + yield 'with-gt' => ['my>stream']; + yield 'with-slash' => ['my/stream']; + yield 'with-backslash' => ['my\\stream']; + yield 'with-null-byte' => ["my\x00stream"]; + yield 'with-del' => ["my\x7fstream"]; + yield 'only-space' => [' ']; + } + + public function testInvalidNameProducesDescriptiveMessage(): void + { + try { + self::invokeValidate('bad.name'); + self::fail('Expected InvalidArgumentException'); + } catch (\InvalidArgumentException $e) { + self::assertSame( + "Invalid stream name 'bad.name': cannot contain whitespace, '.', '*', '>', '/', '\\', or control characters", + $e->getMessage(), + ); + self::assertSame(0, $e->getCode()); + self::assertNull($e->getPrevious()); + } + } + + public function testEmptyNameProducesDescriptiveMessage(): void + { + try { + self::invokeValidate(''); + self::fail('Expected InvalidArgumentException'); + } catch (\InvalidArgumentException $e) { + self::assertSame('Stream name cannot be empty', $e->getMessage()); + self::assertSame(0, $e->getCode()); + self::assertNull($e->getPrevious()); + } + } +}