diff --git a/src/boost/docs-ported.md b/src/boost/docs-ported.md index 3cce4e454..c929e0bd5 100644 --- a/src/boost/docs-ported.md +++ b/src/boost/docs-ported.md @@ -8,6 +8,7 @@ cache.md collections.md concurrency.md configuration.md +console-tests.md container.md pagination.md requests.md diff --git a/src/boost/docs/console-tests.md b/src/boost/docs/console-tests.md index 66199e45e..91132527f 100644 --- a/src/boost/docs/console-tests.md +++ b/src/boost/docs/console-tests.md @@ -8,7 +8,7 @@ ## Introduction -In addition to simplifying HTTP testing, Laravel provides a simple API for testing your application's [custom console commands](/docs/{{version}}/artisan). +In addition to simplifying HTTP testing, Hypervel provides a simple API for testing your application's [custom console commands](/docs/{{version}}/artisan). ## Success / Failure Expectations @@ -17,7 +17,7 @@ To get started, let's explore how to make assertions regarding an Artisan comman ```php tab=Pest test('console command', function () { - $this->artisan('inspire')->assertExitCode(0); + $this->artisan('about')->assertExitCode(0); }); ``` @@ -27,30 +27,34 @@ test('console command', function () { */ public function test_console_command(): void { - $this->artisan('inspire')->assertExitCode(0); + $this->artisan('about')->assertExitCode(0); } ``` You may use the `assertNotExitCode` method to assert that the command did not exit with a given exit code: ```php -$this->artisan('inspire')->assertNotExitCode(1); +$this->artisan('about')->assertNotExitCode(1); ``` Of course, all terminal commands typically exit with a status code of `0` when they are successful and a non-zero exit code when they are not successful. Therefore, for convenience, you may utilize the `assertSuccessful` and `assertFailed` assertions to assert that a given command exited with a successful exit code or not: ```php -$this->artisan('inspire')->assertSuccessful(); +$this->artisan('about')->assertSuccessful(); -$this->artisan('inspire')->assertFailed(); +$this->artisan('example:failing-command')->assertFailed(); ``` +The `assertOk` method is also available as an alias of the `assertSuccessful` method. + ## Input / Output Expectations -Laravel allows you to easily "mock" user input for your console commands using the `expectsQuestion` method. In addition, you may specify the exit code and text that you expect to be output by the console command using the `assertExitCode` and `expectsOutput` methods. For example, consider the following console command: +Hypervel allows you to easily "mock" user input for your console commands using the `expectsQuestion` method. In addition, you may specify the exit code and text that you expect to be output by the console command using the `assertExitCode` and `expectsOutput` methods. For example, consider the following console command: ```php +use Hypervel\Support\Facades\Artisan; + Artisan::command('question', function () { $name = $this->ask('What is your name?'); @@ -70,7 +74,11 @@ You may test this command with the following test: test('console command', function () { $this->artisan('question') ->expectsQuestion('What is your name?', 'Taylor Otwell') - ->expectsQuestion('Which language do you prefer?', 'PHP') + ->expectsChoice('Which language do you prefer?', 'PHP', [ + 'PHP', + 'Ruby', + 'Python', + ]) ->expectsOutput('Your name is Taylor Otwell and you prefer PHP.') ->doesntExpectOutput('Your name is Taylor Otwell and you prefer Ruby.') ->assertExitCode(0); @@ -85,14 +93,20 @@ public function test_console_command(): void { $this->artisan('question') ->expectsQuestion('What is your name?', 'Taylor Otwell') - ->expectsQuestion('Which language do you prefer?', 'PHP') + ->expectsChoice('Which language do you prefer?', 'PHP', [ + 'PHP', + 'Ruby', + 'Python', + ]) ->expectsOutput('Your name is Taylor Otwell and you prefer PHP.') ->doesntExpectOutput('Your name is Taylor Otwell and you prefer Ruby.') ->assertExitCode(0); } ``` -If you are utilizing the `search` or `multisearch` functions provided by [Laravel Prompts](/docs/{{version}}/prompts), you may use the `expectsSearch` assertion to mock the user's input, search results, and selection: +If the order of the available choices is important, you may pass `true` as the fourth argument to the `expectsChoice` method to assert that the choices are offered in the exact order provided. + +If you are utilizing the `search` or `multisearch` functions provided by [Hypervel Prompts](/docs/{{version}}/prompts), you may use the `expectsSearch` assertion to mock the user's input, search results, and selection: ```php tab=Pest test('console command', function () { @@ -193,6 +207,29 @@ $this->artisan('users:all') ]); ``` + +#### Prompts Output Expectations + +If your command displays messages or tables using [Hypervel Prompts](/docs/{{version}}/prompts), you may use the `expectsPromptsInfo`, `expectsPromptsWarning`, `expectsPromptsError`, `expectsPromptsAlert`, `expectsPromptsIntro`, `expectsPromptsOutro`, and `expectsPromptsTable` methods: + +```php +$this->artisan('report:generate') + ->expectsPromptsInfo('Welcome to the application!') + ->expectsPromptsWarning('This action cannot be undone') + ->expectsPromptsError('Something went wrong') + ->expectsPromptsAlert('Important notice!') + ->expectsPromptsIntro('Starting process...') + ->expectsPromptsOutro('Process completed!') + ->expectsPromptsTable( + headers: ['Name', 'Email'], + rows: [ + ['Taylor Otwell', 'taylor@example.com'], + ['Jason Beggs', 'jason@example.com'], + ] + ) + ->assertExitCode(0); +``` + ## Console Events diff --git a/src/jwt/src/Blacklist.php b/src/jwt/src/Blacklist.php index ae8a942bf..60a38d108 100644 --- a/src/jwt/src/Blacklist.php +++ b/src/jwt/src/Blacklist.php @@ -120,6 +120,10 @@ protected function getGraceTimestamp(): int /** * Set the grace period. * + * Only call this once at boot (service provider). Blacklist is a + * worker-lifetime singleton; runtime mutation affects every subsequent + * request and races across coroutines. + * * @return $this */ public function setGracePeriod(int $gracePeriod): static @@ -152,6 +156,10 @@ public function getKey(array $payload): mixed /** * Set the unique key held within the blacklist. * + * Only call this once at boot (service provider). Blacklist is a + * worker-lifetime singleton; runtime mutation affects every subsequent + * request and races across coroutines. + * * @return $this */ public function setKey(string $key): static @@ -164,6 +172,10 @@ public function setKey(string $key): static /** * Set the refresh time limit. * + * Only call this once at boot (service provider). Blacklist is a + * worker-lifetime singleton; runtime mutation affects every subsequent + * request and races across coroutines. + * * @return $this */ public function setRefreshTTL(int $ttl): static diff --git a/src/jwt/src/Providers/Lcobucci.php b/src/jwt/src/Providers/Lcobucci.php index abb4c01a3..2b296f3b9 100644 --- a/src/jwt/src/Providers/Lcobucci.php +++ b/src/jwt/src/Providers/Lcobucci.php @@ -166,6 +166,19 @@ protected function buildConfig(): Configuration return $config; } + /** + * Rebuild cached derived state after a configuration change. + * + * Signer is rebuilt before config because buildConfig() reads $this->signer. + * + * @throws \Hypervel\JWT\Exceptions\JWTException + */ + protected function onConfigurationChanged(): void + { + $this->signer = $this->getSigner(); + $this->config = $this->buildConfig(); + } + /** * Get the signer instance. * diff --git a/src/jwt/src/Providers/Provider.php b/src/jwt/src/Providers/Provider.php index c3c269c9b..f4dc705d2 100644 --- a/src/jwt/src/Providers/Provider.php +++ b/src/jwt/src/Providers/Provider.php @@ -39,11 +39,16 @@ public function __construct( /** * Set the algorithm used to sign the token. * + * Only call this once at boot (service provider). Providers are cached + * on the singleton JWTManager; runtime mutation affects every subsequent + * request and races across coroutines. + * * @return $this */ public function setAlgo(string $algo): static { $this->algo = $algo; + $this->onConfigurationChanged(); return $this; } @@ -59,11 +64,16 @@ public function getAlgo(): string /** * Set the secret used to sign the token. * + * Only call this once at boot (service provider). Providers are cached + * on the singleton JWTManager; runtime mutation affects every subsequent + * request and races across coroutines. + * * @return $this */ public function setSecret(string $secret): static { $this->secret = $secret; + $this->onConfigurationChanged(); return $this; } @@ -79,11 +89,16 @@ public function getSecret(): string /** * Set the keys used to sign the token. * + * Only call this once at boot (service provider). Providers are cached + * on the singleton JWTManager; runtime mutation affects every subsequent + * request and races across coroutines. + * * @return $this */ public function setKeys(array $keys): static { $this->keys = $keys; + $this->onConfigurationChanged(); return $this; } @@ -141,4 +156,14 @@ protected function getVerificationKey(): mixed * Determine if the algorithm is asymmetric, and thus requires a public/private key combo. */ abstract protected function isAsymmetric(): bool; + + /** + * Hook fired after any of algo/secret/keys is mutated. + * + * Subclasses that cache derived state (signers, configuration objects) + * override this to rebuild it so the new values actually take effect. + */ + protected function onConfigurationChanged(): void + { + } } diff --git a/tests/Integration/Routing/MethodOverrideTest.php b/tests/Integration/Routing/MethodOverrideTest.php new file mode 100644 index 000000000..2988222a2 --- /dev/null +++ b/tests/Integration/Routing/MethodOverrideTest.php @@ -0,0 +1,28 @@ + "updated-{$widget}"); + + $this->post('/widgets/42', ['_method' => 'PUT']) + ->assertOk() + ->assertContent('updated-42'); + } + + public function testQueryStringMethodOverrideDispatchesToPutRoute() + { + Route::put('/widgets/{widget}', fn (string $widget) => "updated-{$widget}"); + + $this->post('/widgets/42?_method=PUT') + ->assertOk() + ->assertContent('updated-42'); + } +} diff --git a/tests/JWT/Providers/keys/id_rsa b/tests/JWT/Fixtures/keys/id_rsa similarity index 100% rename from tests/JWT/Providers/keys/id_rsa rename to tests/JWT/Fixtures/keys/id_rsa diff --git a/tests/JWT/Providers/keys/id_rsa.pub b/tests/JWT/Fixtures/keys/id_rsa.pub similarity index 100% rename from tests/JWT/Providers/keys/id_rsa.pub rename to tests/JWT/Fixtures/keys/id_rsa.pub diff --git a/tests/JWT/Fixtures/keys/id_rsa_alt b/tests/JWT/Fixtures/keys/id_rsa_alt new file mode 100644 index 000000000..adc866589 --- /dev/null +++ b/tests/JWT/Fixtures/keys/id_rsa_alt @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDLJsCnfubmsSOx +JkG1m4kgMdKjtEUdpwukZr6ejWWP5dTnZJgOI+O0wWMgspNHPzhlQpBk4xg0fWnf +oMsU4eFG178Lj1135AIPt3tBuVudZREmxTpC5H65A+J6L6iU9wNQVDW1JM+p8qQg +v3nWi8kOOSk9xgp6nLfD6qZO83lro5wKTLhmAPpHivfwDg5B4v9DVDjXeW0cnrHN +dcfYQi7e4DlQ5n3nyxOh7BXnc0Xk95EskWuzkuuoTqnhDE9ixYJB+6R2UhoAXfSs +JxgQwmVLSSExqEfu/a1C4Jc0cFtIxNchVpnXsAIQb9rQUjNdkPJ9Dl9NnGDke/B8 +QN1rOpfrAgMBAAECggEAObUV596AB8sM6P8FjmmSv873V2JXec9gWsCG6HIhBgnS ++zITkXy3a7q9hmXDuWrngbgxXT83On3fq9M4rQNnI2EY11dPxUVDIqTLwgU91Y2G +nD1NSgAvUS4d0Pilyj8KtXBeW0kMJmoTHmxV11CY+c3Z/b66bk8DUode27GE7cKn +0plc+56arqYwEr2LqDLy2Sr7GiB57FDSHlB0gd2aDH7oPjg2KFsq1OOIGIWvVZf9 +g3g53usStoA0PK/iAnRnxbrYsF1fxFOO65EIW5A9Xu+X7UygJr4LtalBdYUsuW+H +5+156BN9qr57LNy9upz+QVexFihM1mM8vjOmmUkZxQKBgQDvfBKGQ6f+NiP5/1Mb +4S9VqgWLbtRR7j5n87L8sjJicWa6lqXU0pnoO2S2BAqo48uF+TEuPc3XsTKAo5e6 +ByBddNVHRRc4dk19MmomZESuSgZPVGl2lVDxlUEWiqUxm1JZD4NWl4BRBu1fUVVC +eC080TJvu0fzvb03+65sYtSe1wKBgQDZKT4WGHrZjIMYsABkWDyI6tyGTHs4HduH +LCTChbF3WOyI3gpyqUucT6mRNpmMj6atBNvrww12dSClyVvgahzRVFfzvrAroNT3 +86E1dY92NOd/6kmniNpAGY6ZxOxHgKDgqOQhgSRGUUE4WdOTdVdgaLSmRc5BbTub +kgmekifRDQKBgQCpO5w7lQPZ+JhjybIJOZAhkQxqA9+2Jg2jl+sb9zDzr/9QS7TU +OB7apV394c1Gm/LusbG3Y6VajrWJghFuPCr639z3iDNoivEnT8EDcEe4gkcDODtu +uQWCMl7UdxVVgNUoanX9cwISQDrt6KO/XP1axNpHaqjl7WcRcKFJpm1p8QKBgQCf +gUyKHDb33d5Y3sWa7rif9Ko/tqN2529cjM7/VgWw3M74BOd1quXPjS/Gam1EMitB +wGTseZtE2+k7/HeQkUBTfPkRHon1sa9b5EYPpybVsywq52JsPPfZxyvXxC2so00H +VuYhueJ0B7C4/DmMgM7KEH5H9tP1eI8kyJJqN34pbQKBgAi+QUUkTzjGsmGJcx/s +7ulPWIZLpUlcwB8P4y7+crPJ4JprNuc+LVdAKi0H/JOGpUgkwcvsRMhNmtstZeQ9 +B0lP5Oxs4hr1CjQ7Gz5v4fFvl1T33Oa0ThyqrlZ9eT8i3TFToXU3xXA/qeqglZff +986zyaNhxIG04t1SxmHNX7YM +-----END PRIVATE KEY----- diff --git a/tests/JWT/Fixtures/keys/id_rsa_alt.pub b/tests/JWT/Fixtures/keys/id_rsa_alt.pub new file mode 100644 index 000000000..5eba34924 --- /dev/null +++ b/tests/JWT/Fixtures/keys/id_rsa_alt.pub @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyybAp37m5rEjsSZBtZuJ +IDHSo7RFHacLpGa+no1lj+XU52SYDiPjtMFjILKTRz84ZUKQZOMYNH1p36DLFOHh +Rte/C49dd+QCD7d7QblbnWURJsU6QuR+uQPiei+olPcDUFQ1tSTPqfKkIL951ovJ +DjkpPcYKepy3w+qmTvN5a6OcCky4ZgD6R4r38A4OQeL/Q1Q413ltHJ6xzXXH2EIu +3uA5UOZ958sToewV53NF5PeRLJFrs5LrqE6p4QxPYsWCQfukdlIaAF30rCcYEMJl +S0khMahH7v2tQuCXNHBbSMTXIVaZ17ACEG/a0FIzXZDyfQ5fTZxg5HvwfEDdazqX +6wIDAQAB +-----END PUBLIC KEY----- diff --git a/tests/JWT/Providers/LcobucciTest.php b/tests/JWT/Providers/LcobucciTest.php index fcf781558..61a75819e 100644 --- a/tests/JWT/Providers/LcobucciTest.php +++ b/tests/JWT/Providers/LcobucciTest.php @@ -17,6 +17,8 @@ class LcobucciTest extends TestCase protected function setUp(): void { + parent::setUp(); + Carbon::setTestNow('2000-01-01T00:00:00.000000Z'); $this->testNowTimestamp = Carbon::now()->timestamp; @@ -199,6 +201,63 @@ public function testShouldReturnTheKeys() $this->assertSame($keys, $provider->getKeys()); } + public function testSetAlgoTakesEffectOnEncoding() + { + $payload = ['sub' => 1, 'iat' => $this->testNowTimestamp]; + + $provider = $this->getProvider($this->getRandomString(), Provider::ALGO_HS256); + $provider->setAlgo(Provider::ALGO_HS512); + + $token = $provider->encode($payload); + $header = json_decode(base64_decode(explode('.', $token)[0]), true); + + $this->assertEquals(Provider::ALGO_HS512, $header['alg']); + } + + public function testSetSecretTakesEffectOnSigning() + { + $payload = ['sub' => 1, 'iat' => $this->testNowTimestamp]; + + $originalSecret = $this->getRandomString(); + $rotatedSecret = $this->getRandomString(); + + $provider = $this->getProvider($originalSecret, Provider::ALGO_HS256); + $provider->setSecret($rotatedSecret); + + $token = $provider->encode($payload); + + // A fresh provider using the new secret decodes the token successfully. + $decoded = $this->getProvider($rotatedSecret, Provider::ALGO_HS256)->decode($token); + $this->assertEquals('1', $decoded['sub']); + + // A fresh provider using the old secret cannot verify the signature. + $this->expectException(TokenInvalidException::class); + $this->expectExceptionMessage('Token Signature could not be verified.'); + $this->getProvider($originalSecret, Provider::ALGO_HS256)->decode($token); + } + + public function testSetKeysTakesEffectOnSigning() + { + $payload = ['sub' => 1, 'iat' => $this->testNowTimestamp]; + + $keyPair1 = ['private' => $this->getDummyPrivateKey(), 'public' => $this->getDummyPublicKey()]; + $keyPair2 = ['private' => $this->getAltPrivateKey(), 'public' => $this->getAltPublicKey()]; + + $provider = $this->getProvider('does_not_matter', Provider::ALGO_RS256, $keyPair1); + $provider->setKeys($keyPair2); + + $token = $provider->encode($payload); + + // A fresh provider using key pair 2 decodes the token successfully. + $decoded = $this->getProvider('does_not_matter', Provider::ALGO_RS256, $keyPair2)->decode($token); + $this->assertEquals('1', $decoded['sub']); + + // A fresh provider using key pair 1 cannot verify the signature. + $this->expectException(TokenInvalidException::class); + $this->expectExceptionMessage('Token Signature could not be verified.'); + $this->getProvider('does_not_matter', Provider::ALGO_RS256, $keyPair1)->decode($token); + } + private function getProvider(string $secret, string $algo, array $keys = []): Lcobucci { return new Lcobucci($secret, $algo, $keys); @@ -219,11 +278,21 @@ private function getRandomString(int $length = 64) private function getDummyPrivateKey() { - return file_get_contents(__DIR__ . '/keys/id_rsa'); + return file_get_contents(__DIR__ . '/../Fixtures/keys/id_rsa'); } private function getDummyPublicKey() { - return file_get_contents(__DIR__ . '/keys/id_rsa.pub'); + return file_get_contents(__DIR__ . '/../Fixtures/keys/id_rsa.pub'); + } + + private function getAltPrivateKey() + { + return file_get_contents(__DIR__ . '/../Fixtures/keys/id_rsa_alt'); + } + + private function getAltPublicKey() + { + return file_get_contents(__DIR__ . '/../Fixtures/keys/id_rsa_alt.pub'); } } diff --git a/tests/JWT/Providers/ProviderTest.php b/tests/JWT/Providers/ProviderTest.php index 51bd172ed..020bdf79a 100644 --- a/tests/JWT/Providers/ProviderTest.php +++ b/tests/JWT/Providers/ProviderTest.php @@ -28,4 +28,13 @@ public function testSetTheSecret() $this->assertSame('foo', $provider->getSecret()); } + + public function testSetTheKeys() + { + $provider = new ProviderStub('secret', 'HS256', []); + + $provider->setKeys($keys = ['private' => 'priv', 'public' => 'pub']); + + $this->assertSame($keys, $provider->getKeys()); + } }