From d302a1a56211a6071e487bf9840526260dcc92dc Mon Sep 17 00:00:00 2001 From: Kevin Bond Date: Wed, 14 Sep 2022 12:27:36 -0400 Subject: [PATCH 1/2] [minor][BC BREAK] `VerificationFailed::uri()` now returns `Uri` Previously, it returned `Zenstruck\Uri\SignedUri`. Now it returns `Zenstruck\Uri`. --- README.md | 12 ++++++------ src/Uri/Signed/Exception/ExpiredUri.php | 18 ++++++++++++++---- .../Signed/Exception/VerificationFailed.php | 11 +++++++---- src/Uri/SignedUri.php | 2 +- 4 files changed, 28 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index d85411a..e2cb52f 100644 --- a/README.md +++ b/README.md @@ -287,7 +287,7 @@ try { $signedUri->verify('a secret'); } catch (VerificationFailed $e) { $e::REASON; // ie "Invalid signature." - $e->uri(); // SignedUri + $e->uri(); // \Zenstruck\Uri } // catch specific exceptions @@ -295,10 +295,10 @@ try { $signedUri->verify('a secret'); } catch (InvalidSignature $e) { $e::REASON; // "Invalid signature." - $e->uri(); // SignedUri + $e->uri(); // \Zenstruck\Uri } catch (ExpiredUri $e) { $e::REASON; // "URI has expired." - $e->uri(); // SignedUri + $e->uri(); // \Zenstruck\Uri $e->expiredAt(); // \DateTimeImmutable } ``` @@ -321,14 +321,14 @@ try { $signedUri->verify('a secret', 'some token'); } catch (InvalidSignature $e) { $e::REASON; // "Invalid signature." - $e->uri(); // SignedUri + $e->uri(); // \Zenstruck\Uri } catch (ExpiredUri $e) { $e::REASON; // "URI has expired." - $e->uri(); // SignedUri + $e->uri(); // \Zenstruck\Uri $e->expiredAt(); // \DateTimeImmutable } catch (UriAlreadyUsed $e) { $e::REASON; // "URI has already been used." - $e->uri(); // SignedUri + $e->uri(); // \Zenstruck\Uri } ``` diff --git a/src/Uri/Signed/Exception/ExpiredUri.php b/src/Uri/Signed/Exception/ExpiredUri.php index 6e1e2a6..98fea4a 100644 --- a/src/Uri/Signed/Exception/ExpiredUri.php +++ b/src/Uri/Signed/Exception/ExpiredUri.php @@ -2,6 +2,8 @@ namespace Zenstruck\Uri\Signed\Exception; +use Zenstruck\Uri; + /** * @author Kevin Bond */ @@ -9,12 +11,20 @@ final class ExpiredUri extends VerificationFailed { public const REASON = 'URI has expired.'; - public function expiredAt(): \DateTimeImmutable + private \DateTimeImmutable $expiredAt; + + /** + * @internal + */ + public function __construct(Uri $uri, \DateTimeImmutable $expiredAt, ?string $message = null, ?\Throwable $previous = null) { - $expiredAt = $this->uri()->expiresAt(); + parent::__construct($uri, $message, $previous); - \assert($expiredAt instanceof \DateTimeImmutable); + $this->expiredAt = $expiredAt; + } - return $expiredAt; + public function expiredAt(): \DateTimeImmutable + { + return $this->expiredAt; } } diff --git a/src/Uri/Signed/Exception/VerificationFailed.php b/src/Uri/Signed/Exception/VerificationFailed.php index 59248fd..6442e91 100644 --- a/src/Uri/Signed/Exception/VerificationFailed.php +++ b/src/Uri/Signed/Exception/VerificationFailed.php @@ -2,7 +2,7 @@ namespace Zenstruck\Uri\Signed\Exception; -use Zenstruck\Uri\SignedUri; +use Zenstruck\Uri; /** * @author Kevin Bond @@ -11,16 +11,19 @@ abstract class VerificationFailed extends \RuntimeException { public const REASON = ''; - private SignedUri $uri; + private Uri $uri; - final public function __construct(SignedUri $uri, ?string $message = null, ?\Throwable $previous = null) + /** + * @internal + */ + public function __construct(Uri $uri, ?string $message = null, ?\Throwable $previous = null) { $this->uri = $uri; parent::__construct($message ?? static::REASON, 0, $previous); } - final public function uri(): SignedUri + final public function uri(): Uri { return $this->uri; } diff --git a/src/Uri/SignedUri.php b/src/Uri/SignedUri.php index d3b9cf1..ad50fbd 100644 --- a/src/Uri/SignedUri.php +++ b/src/Uri/SignedUri.php @@ -46,7 +46,7 @@ public function verify($secret, ?string $singleUseToken = null): void $expiresAt = $this->expiresAt(); if ($expiresAt && $expiresAt < new \DateTimeImmutable('now')) { - throw new ExpiredUri($this); + throw new ExpiredUri($this, $expiresAt); } $singleUseSignature = $this->query()->get(self::SINGLE_USE_TOKEN_KEY); From 389cf9107f07a79da6db146c512d1839e40891cd Mon Sep 17 00:00:00 2001 From: Kevin Bond Date: Wed, 14 Sep 2022 16:23:38 -0400 Subject: [PATCH 2/2] [feature][BC BREAK] `SignedUri` always exists in a verified state - Adds `Uri::verified()` - Adds `Uri::isVerified()` --- README.md | 81 ++++++++++++++---------- src/Uri.php | 45 ++++++++++--- src/Uri/Signed/Builder.php | 36 +++++++---- src/Uri/SignedUri.php | 125 ++++++++++++++++++++++--------------- tests/SignedUriTest.php | 55 ++++++++++++---- 5 files changed, 226 insertions(+), 116 deletions(-) diff --git a/README.md b/README.md index e2cb52f..42fd518 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Object-oriented wrapper/manipulator for `parse_url` with the following features: * Read URI _parts_ as objects (`Scheme`, `Host`, `Path`, `Query`), each with their own set of features. * Manipulate URI parts or build URI's using a fluent builder API. +* Sign and verify URI's and make them temporary and/or single-use. * Mailto object to help with reading/manipulating `mailto:` URIs. * Optional [twig](https://twig.symfony.com/) extension. @@ -225,61 +226,44 @@ These URIs are generated with a token that should change _once the URI has been $uri = Zenstruck\Uri::new('https://example.com/some/path'); (string) $uri->sign('a secret')->singleUse('some-token'); // "https://example.com/some/path?_token=...&_hash=..." - -// create a single-use, temporary uri -(string) $uri->sign('a secret') - ->singleUse('some-token') - ->expires('+30 minutes') -; // "https://example.com/some/path?_expires=...&_token=...&_hash=..." ``` > **Note**: The URL is first hashed with this token, then hashed again with secret to ensure it hasn't > been tampered with. -### `SignedUri` - -Calling `Zenstruck\Uri::sign()` returns a `Zenstruck\Uri\Signed\Builder` object. Calling `Builder::create()` -returns a `Zenstruck\Uri\SignedUri` object that extends `Zenstruck\Uri` and has some helpful methods. +### Signed URI Builder -> **Note**: `Zenstruck\Uri\Signed\Builder` is immutable objects so any manipulations results in a new instance. - -> **Note**: `Zenstruck\Uri\SignedUri` cannot be cloned so the [manipulation methods](#manipulating-uris) cannot be called. +Calling `Zenstruck\Uri::sign()` returns a `Zenstruck\Uri\Signed\Builder` object that can be used to +create single-use _and_ temporary URIs. ```php $uri = Zenstruck\Uri::new('https://example.com/some/path'); $builder = $uri->sign('a secret'); // Zenstruck\Uri\Signed\Builder -$signedUri = $builder - ->singleUse('a token') - ->expires('tomorrow') - ->create() -; // Zenstruck\Uri\SignedUri - -$signedUri->isSingleUse(); // true -$signedUri->isTemporary(); // true -$signedUri->expiresAt(); // \DateTimeImmutable +// create a single-use, temporary uri +$builder = $uri->sign('a secret') + ->singleUse('some-token') + ->expires('+30 minutes') +; -// extends Zenstruck\Uri -$signedUri->query(); // Zenstruck\Uri\Query +(string) $builder; // "https://example.com/some/path?_expires=...&_token=...&_hash=..." ``` +> **Note**: `Zenstruck\Uri\Signed\Builder` is immutable objects so any manipulations results in a new instance. + ### Verification -To verify a signed URI, create an instance of `Zenstruck\Uri\SignedUri` and call `SignedUri::isVerified()` to -get true/false or `SignedUri::verify()` to throw specific exceptions: +To verify a signed URI, create an instance of `Zenstruck\Uri` and call `Uri::isVerified()` to +get true/false or `Uri::verify()` to throw specific exceptions: ```php -use Zenstruck\Uri\SignedUri; +use Zenstruck\Uri; use Zenstruck\Uri\Signed\Exception\InvalidSignature; use Zenstruck\Uri\Signed\Exception\ExpiredUri; use Zenstruck\Uri\Signed\Exception\VerificationFailed; -$signedUri = SignedUri::new('http://example.com/some/path?_hash=...'); - -// can also create from an instance of Symfony\Component\HttpFoundation\Request -/** @var Symfony\Component\HttpFoundation\Request $request */ -$signedUri = SignedUri::new($request); +$signedUri = Uri::new('http://example.com/some/path?_hash=...'); $signedUri->isVerified('a secret'); // true/false @@ -312,7 +296,7 @@ use Zenstruck\Uri\Signed\Exception\InvalidSignature; use Zenstruck\Uri\Signed\Exception\ExpiredUri; use Zenstruck\Uri\Signed\Exception\UriAlreadyUsed; -/** @var \Zenstruck\Uri\SignedUri $uri */ +/** @var \Zenstruck\Uri $uri */ $uri->isVerified('a secret', 'some token'); // true/false @@ -332,6 +316,37 @@ try { } ``` +### `SignedUri` + +`Zenstruck\Uri\Signed\Builder::create()` and `Zenstruck\Uri::verify()` both return a `Zenstruck\Uri\SignedUri` +object that extends `Zenstruck\Uri` and has some helpful methods. + +> **Note**: `Zenstruck\Uri\SignedUri` is: +> 1. Always considered verified. +> 2. Cannot be cloned so the [manipulation methods](#manipulating-uris) cannot be called. +> 3. Cannot be constructed directly, must be created through `Uri::verify()` or `Builder::create()` + +```php +$uri = Zenstruck\Uri::new('https://example.com/some/path'); + +// create from the builder +$signedUri = $uri->sign('a secret') + ->singleUse('a token') + ->expires('tomorrow') + ->create() +; // Zenstruck\Uri\SignedUri + +// create from verify +$uri->verify('a secret'); // Zenstruck\Uri\SignedUri + +$signedUri->isSingleUse(); // true +$signedUri->isTemporary(); // true +$signedUri->expiresAt(); // \DateTimeImmutable + +// extends Zenstruck\Uri +$signedUri->query(); // Zenstruck\Uri\Query +``` + ## `Mailto` URIs > **Note**: `Zenstruck\Uri\Mailto` is an immutable object so any manipulations results in a new diff --git a/src/Uri.php b/src/Uri.php index eab93a7..7e9ae19 100644 --- a/src/Uri.php +++ b/src/Uri.php @@ -10,6 +10,11 @@ use Zenstruck\Uri\Query; use Zenstruck\Uri\Scheme; use Zenstruck\Uri\Signed\Builder; +use Zenstruck\Uri\Signed\Exception\ExpiredUri; +use Zenstruck\Uri\Signed\Exception\InvalidSignature; +use Zenstruck\Uri\Signed\Exception\UriAlreadyUsed; +use Zenstruck\Uri\Signed\Exception\VerificationFailed; +use Zenstruck\Uri\SignedUri; use Zenstruck\Uri\Stringable; /** @@ -30,7 +35,7 @@ class Uri implements \Stringable /** * @param string|self|null $value */ - final public function __construct($value = null) + public function __construct($value = null) { if ($value instanceof self) { $this->createFromSelf($value); @@ -56,19 +61,17 @@ final public function __construct($value = null) /** * @param string|self|Request|null $value - * - * @return static */ - final public static function new($value = null): self + public static function new($value = null): self { if ($value instanceof Request) { - $value = (new self($value->getUri())) + return (new self($value->getUri())) ->withUser($value->getUser()) ->withPass($value->getPassword()) ; } - return $value instanceof static ? $value : new static($value); + return $value instanceof self && self::class === \get_class($value) ? $value : new self($value); } final public function scheme(): Scheme @@ -339,11 +342,39 @@ final public function withoutFragment(): self /** * @param string|UriSigner $secret */ - public function sign($secret): Builder + final public function sign($secret): Builder { return new Builder($this, $secret); } + /** + * @param string|UriSigner $secret + * @param string|null $singleUseToken If passed, this value MUST change once the URL is considered "used" + * + * @throws ExpiredUri if the URI has expired + * @throws UriAlreadyUsed if the URI has already been used + * @throws InvalidSignature if the URI could not be verified + */ + final public function verify($secret, ?string $singleUseToken = null): SignedUri + { + return SignedUri::createVerified($this, $secret, $singleUseToken); + } + + /** + * @param string|UriSigner $secret + * @param string|null $singleUseToken If passed, this value MUST change once the URL is considered "used" + */ + public function isVerified($secret, ?string $singleUseToken = null): bool + { + try { + $this->verify($secret, $singleUseToken); + + return true; + } catch (VerificationFailed $e) { + return false; + } + } + final protected function generateString(): string { $ret = ''; diff --git a/src/Uri/Signed/Builder.php b/src/Uri/Signed/Builder.php index 058f980..a1f56aa 100644 --- a/src/Uri/Signed/Builder.php +++ b/src/Uri/Signed/Builder.php @@ -15,10 +15,12 @@ final class Builder implements \Stringable { private Uri $uri; private UriSigner $signer; - private ?\DateTimeInterface $expiresAt = null; + private ?\DateTimeImmutable $expiresAt = null; private ?string $singleUseToken = null; /** + * @internal + * * @param string|UriSigner $secret */ public function __construct(Uri $uri, $secret) @@ -27,6 +29,10 @@ public function __construct(Uri $uri, $secret) throw new \LogicException('symfony/http-kernel is required to sign URIs. composer require symfony/http-kernel.'); } + if ($uri instanceof SignedUri) { + throw new \LogicException(\sprintf('"%s" is already signed.', $uri)); + } + $this->uri = $uri; $this->signer = $secret instanceof UriSigner ? $secret : new UriSigner($secret); } @@ -47,17 +53,21 @@ public function __toString(): string public function expires($when): self { if (\is_numeric($when)) { - $when = \DateTime::createFromFormat('U', (string) (\time() + $when)); + $when = \DateTimeImmutable::createFromFormat('U', (string) (\time() + $when)); } if (\is_string($when)) { - $when = new \DateTime($when); + $when = new \DateTimeImmutable($when); } if ($when instanceof \DateInterval) { $when = (new \DateTime('now'))->add($when); } + if ($when instanceof \DateTime) { + $when = \DateTimeImmutable::createFromMutable($when); + } + if (!$when instanceof \DateTimeInterface) { throw new \InvalidArgumentException(\sprintf('%s is not a valid expires at.', get_debug_type($when))); } @@ -83,16 +93,16 @@ public function singleUse(string $token): self public function create(): SignedUri { - $uri = $this->uri; - - if ($this->expiresAt) { - $uri = $uri->withQueryParam(SignedUri::EXPIRES_AT_KEY, $this->expiresAt->getTimestamp()); - } - - if ($this->singleUseToken) { - $uri = (new UriSigner($this->singleUseToken, SignedUri::SINGLE_USE_TOKEN_KEY))->sign($uri); - } + return SignedUri::new($this); + } - return new SignedUri($this->signer->sign($uri)); + /** + * @internal + * + * @return array{0:Uri,1:UriSigner,2:\DateTimeImmutable|null,3:string|null} + */ + public function context(): array + { + return [$this->uri, $this->signer, $this->expiresAt, $this->singleUseToken]; } } diff --git a/src/Uri/SignedUri.php b/src/Uri/SignedUri.php index ad50fbd..ec67b84 100644 --- a/src/Uri/SignedUri.php +++ b/src/Uri/SignedUri.php @@ -8,108 +8,129 @@ use Zenstruck\Uri\Signed\Exception\ExpiredUri; use Zenstruck\Uri\Signed\Exception\InvalidSignature; use Zenstruck\Uri\Signed\Exception\UriAlreadyUsed; -use Zenstruck\Uri\Signed\Exception\VerificationFailed; /** * @author Kevin Bond */ final class SignedUri extends Uri { - public const EXPIRES_AT_KEY = '_expires'; - public const SINGLE_USE_TOKEN_KEY = '_token'; + private const EXPIRES_AT_KEY = '_expires'; + private const SINGLE_USE_TOKEN_KEY = '_token'; + + private ?\DateTimeImmutable $expiresAt; + + /** + * @param string|Uri $uri + */ + private function __construct($uri, ?\DateTimeImmutable $expiresAt) + { + $this->expiresAt = $expiresAt; + + parent::__construct($uri); + } public function __clone() { - throw new \LogicException(\sprintf('%s (%s) cannot be cloned.', static::class, $this)); + throw new \LogicException(\sprintf('%s (%s) cannot be cloned.', self::class, $this)); } /** - * @param string|UriSigner $secret - * @param string|null $singleUseToken If passed, this value MUST change once the URL is considered "used" + * @internal * - * @throws ExpiredUri if the URI has expired - * @throws UriAlreadyUsed if the URI has already been used - * @throws InvalidSignature if the URI could not be verified + * @param Builder $builder + */ + public static function new($builder = null): self + { + if (!$builder instanceof Builder) { + throw new \LogicException(\sprintf('"%s" is internal and cannot be called directly.', __METHOD__)); + } + + [$uri, $signer, $expiresAt, $singleUseToken] = $builder->context(); + + if ($expiresAt) { + $uri = $uri->withQueryParam(self::EXPIRES_AT_KEY, $expiresAt->getTimestamp()); + } + + if ($singleUseToken) { + $uri = (new UriSigner($singleUseToken, self::SINGLE_USE_TOKEN_KEY))->sign($uri); + } + + return new self($signer->sign($uri), $expiresAt); + } + + public function expiresAt(): ?\DateTimeImmutable + { + return $this->expiresAt; + } + + public function isTemporary(): bool + { + return $this->expiresAt instanceof \DateTimeImmutable; + } + + public function isSingleUse(): bool + { + return $this->query()->has(self::SINGLE_USE_TOKEN_KEY); + } + + /** + * @param string|UriSigner $secret */ - public function verify($secret, ?string $singleUseToken = null): void + protected static function createVerified(Uri $uri, $secret, ?string $singleUseToken): self { if (!\class_exists(UriSigner::class)) { throw new \LogicException('symfony/http-kernel is required to verify signed URIs. composer require symfony/http-kernel.'); } + if ($uri instanceof self) { + throw new \LogicException(\sprintf('"%s" is already signed.', $uri)); + } + $signer = $secret instanceof UriSigner ? $secret : new UriSigner($secret); - if (!$signer->check($this)) { - throw new InvalidSignature($this); + if (!$signer->check($uri)) { + throw new InvalidSignature($uri); } - $expiresAt = $this->expiresAt(); + $expiresAt = self::calculateExpiresAt($uri); if ($expiresAt && $expiresAt < new \DateTimeImmutable('now')) { - throw new ExpiredUri($this, $expiresAt); + throw new ExpiredUri($uri, $expiresAt); } - $singleUseSignature = $this->query()->get(self::SINGLE_USE_TOKEN_KEY); + $singleUseSignature = $uri->query()->get(self::SINGLE_USE_TOKEN_KEY); if (!$singleUseSignature && !$singleUseToken) { - return; + return new self($uri, $expiresAt); } if ($singleUseSignature && !$singleUseToken) { - throw new InvalidSignature($this, 'URI is single use but this was not expected.'); + throw new InvalidSignature($uri, 'URI is single use but this was not expected.'); } if (!$singleUseSignature && $singleUseToken) { // @phpstan-ignore-line - throw new InvalidSignature($this, 'Expected single use URI.'); + throw new InvalidSignature($uri, 'Expected single use URI.'); } // hack to get the correct parameter used $parameter = \Closure::bind(fn(UriSigner $signer) => $signer->parameter, null, $signer); // remove the _hash query parameter - $uri = (new Uri($this))->withoutQueryParams($parameter($signer)); + $withoutHash = $uri->withoutQueryParams($parameter($signer)); - if (!(new UriSigner($singleUseToken, self::SINGLE_USE_TOKEN_KEY))->check($uri)) { // @phpstan-ignore-line - throw new UriAlreadyUsed($this); + if (!(new UriSigner($singleUseToken, self::SINGLE_USE_TOKEN_KEY))->check($withoutHash)) { // @phpstan-ignore-line + throw new UriAlreadyUsed($uri); } - } - - /** - * @param string|UriSigner $secret - * @param string|null $singleUseToken If passed, this value MUST change once the URL is considered "used" - */ - public function isVerified($secret, ?string $singleUseToken = null): bool - { - try { - $this->verify($secret, $singleUseToken); - return true; - } catch (VerificationFailed $e) { - return false; - } + return new self($uri, $expiresAt); } - public function expiresAt(): ?\DateTimeImmutable + private static function calculateExpiresAt(Uri $uri): ?\DateTimeImmutable { - if ($timestamp = $this->query()->getInt(self::EXPIRES_AT_KEY)) { + if ($timestamp = $uri->query()->getInt(self::EXPIRES_AT_KEY)) { return \DateTimeImmutable::createFromFormat('U', (string) $timestamp) ?: null; } return null; } - - public function isTemporary(): bool - { - return $this->query()->has(self::EXPIRES_AT_KEY); - } - - public function isSingleUse(): bool - { - return $this->query()->has(self::SINGLE_USE_TOKEN_KEY); - } - - public function sign($secret): Builder - { - throw new \BadMethodCallException(\sprintf('%s (%s) is already signed.', static::class, $this)); - } } diff --git a/tests/SignedUriTest.php b/tests/SignedUriTest.php index 0f91368..94d42bc 100644 --- a/tests/SignedUriTest.php +++ b/tests/SignedUriTest.php @@ -37,25 +37,24 @@ public function can_sign_url($uri, $secret, $expiresAt = null, $singleUseToken = $signed = $builder->create(); + $this->assertTrue(Uri::new($signed)->isVerified($secret, $singleUseToken)); $this->assertSame((string) $builder, (string) $signed); - - $this->assertTrue($signed->isVerified($secret, $singleUseToken)); $this->assertTrue($signed->query()->has('_hash')); if ($expiresAt) { $this->assertTrue($signed->isTemporary()); - $this->assertTrue($signed->query()->has(SignedUri::EXPIRES_AT_KEY)); + $this->assertTrue($signed->query()->has('_expires')); } else { $this->assertFalse($signed->isTemporary()); - $this->assertFalse($signed->query()->has(SignedUri::EXPIRES_AT_KEY)); + $this->assertFalse($signed->query()->has('_expires')); } if ($singleUseToken) { $this->assertTrue($signed->isSingleUse()); - $this->assertTrue($signed->query()->has(SignedUri::SINGLE_USE_TOKEN_KEY)); + $this->assertTrue($signed->query()->has('_token')); } else { $this->assertFalse($signed->isSingleUse()); - $this->assertFalse($signed->query()->has(SignedUri::SINGLE_USE_TOKEN_KEY)); + $this->assertFalse($signed->query()->has('_token')); } } @@ -79,7 +78,7 @@ public static function validSignedUrlProvider(): iterable */ public function invalid_signed_url($uri, $secret, $expectedException, $singleUseToken = null): void { - $uri = SignedUri::new($uri); + $uri = Uri::new($uri); $this->assertFalse($uri->isVerified($secret, $singleUseToken)); @@ -149,7 +148,7 @@ public function cannot_create_with_invalid_expires_object(): void */ public function cannot_be_cloned(): void { - $uri = SignedUri::new('/foo'); + $uri = Uri::new('/foo')->sign('foo')->create(); $this->expectException(\LogicException::class); @@ -159,12 +158,46 @@ public function cannot_be_cloned(): void /** * @test */ - public function cannot_resign(): void + public function cannot_re_sign(): void { - $uri = SignedUri::new('/foo'); + $uri = Uri::new('/foo')->sign('foo')->create(); - $this->expectException(\BadMethodCallException::class); + $this->expectException(\LogicException::class); $uri->sign('secret'); } + + /** + * @test + */ + public function cannot_re_verify(): void + { + $uri = Uri::new('/foo')->sign('foo')->create(); + + $this->expectException(\LogicException::class); + + $uri->verify('secret'); + } + + /** + * @test + */ + public function cannot_check_if_verified(): void + { + $uri = Uri::new('/foo')->sign('foo')->create(); + + $this->expectException(\LogicException::class); + + $uri->isVerified('secret'); + } + + /** + * @test + */ + public function cannot_call_new_normally(): void + { + $this->expectException(\LogicException::class); + + SignedUri::new('/foo/bar'); + } }