From 582d70f7ac3dc100efad96b8e1b11cd032f0f146 Mon Sep 17 00:00:00 2001 From: Kevin Bond Date: Thu, 14 Apr 2022 14:34:59 -0400 Subject: [PATCH] [feature] add authentication assertions to `KernelBrowser` (#84) --- README.md | 35 ++++++++++++++---- composer.json | 2 +- phpstan-baseline.neon | 10 ++++++ src/Browser/KernelBrowser.php | 68 ++++++++++++++++++++++++++++++++++- tests/KernelBrowserTests.php | 39 ++++++++++++++++++++ 5 files changed, 146 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 6cc9ee6..58cc6bd 100644 --- a/README.md +++ b/README.md @@ -231,12 +231,6 @@ $browser ->assertXml() ->assertHtml() - // authenticate a user for subsequent actions - ->actingAs($user) // \Symfony\Component\Security\Core\User\UserInterface - - // If using zenstruck/foundry, you can pass a factory/proxy - ->actingAs(UserFactory::new()) - // by default, exceptions are caught and converted to a response // use the BROWSER_CATCH_EXCEPTIONS environment variable to change default // this disables that behaviour allowing you to use TestCase::expectException() @@ -296,6 +290,35 @@ $browser->use(function(\Symfony\Component\HttpKernel\DataCollector\RequestDataCo }) ``` +#### Authentication + +The _KernelBrowser_ has helpers and assertions for authentication: + +```php +/** @var \Zenstruck\Browser\KernelBrowser $browser **/ + +$browser + // authenticate a user for subsequent actions + ->actingAs($user) // \Symfony\Component\Security\Core\User\UserInterface + + // If using zenstruck/foundry, you can pass a factory/proxy + ->actingAs(UserFactory::new()) + + // fail if authenticated + ->assertNotAuthenticated() + + // fail if NOT authenticated + ->assertAuthenticated() + + // fails if NOT authenticated as "kbond" + ->assertAuthenticated('kbond') + + // \Symfony\Component\Security\Core\User\UserInterface or, if using + // zenstruck/foundry, you can pass a factory/proxy + ->assertAuthenticated($user) +; +``` + #### HTTP Requests The _KernelBrowser_ can be used for testing API endpoints. The following http methods are available: diff --git a/composer.json b/composer.json index d930c61..5a04b5e 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,7 @@ "symfony/framework-bundle": "^5.4|^6.0", "symfony/polyfill-php80": "^1.20", "zenstruck/assert": "^1.0", - "zenstruck/callback": "^1.1" + "zenstruck/callback": "^1.4.2" }, "require-dev": { "dbrekelmans/bdi": "^1.0", diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 0126e7c..06c78f6 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -130,6 +130,16 @@ parameters: count: 1 path: src/Browser/HttpOptions.php + - + message: "#^Cannot call method getToken\\(\\) on object\\|null\\.$#" + count: 1 + path: src/Browser/KernelBrowser.php + + - + message: "#^Cannot call method getUserIdentifier\\(\\) on Symfony\\\\Component\\\\Security\\\\Core\\\\Authentication\\\\Token\\\\TokenInterface\\|null\\.$#" + count: 1 + path: src/Browser/KernelBrowser.php + - message: "#^Method Zenstruck\\\\Browser\\\\KernelBrowser\\:\\:delete\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" count: 1 diff --git a/src/Browser/KernelBrowser.php b/src/Browser/KernelBrowser.php index e93fee4..58a5d4f 100644 --- a/src/Browser/KernelBrowser.php +++ b/src/Browser/KernelBrowser.php @@ -5,6 +5,7 @@ use Symfony\Bundle\FrameworkBundle\KernelBrowser as SymfonyKernelBrowser; use Symfony\Component\HttpKernel\DataCollector\DataCollectorInterface; use Symfony\Component\HttpKernel\Profiler\Profile; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\User\UserInterface; use Zenstruck\Assert; use Zenstruck\Browser; @@ -115,7 +116,9 @@ final public function withProfiling(): self } /** - * @param object|UserInterface|Proxy|Factory $user + * @param UserInterface|Proxy|Factory $user + * + * @return static */ public function actingAs(object $user, ?string $firewall = null): self { @@ -136,6 +139,58 @@ public function actingAs(object $user, ?string $firewall = null): self return $this; } + /** + * @param string|UserInterface|Proxy|Factory|null $as + * + * @return static + */ + public function assertAuthenticated($as = null): self + { + Assert::that($token = $this->securityToken()) + ->isNotNull('Expected to be authenticated but NOT.') + ; + + if (!$as) { + return $this; + } + + if ($as instanceof Factory) { + $as = $as->create(); + } + + if ($as instanceof Proxy) { + $as = $as->object(); + } + + if ($as instanceof UserInterface) { + $as = $as->getUserIdentifier(); + } + + if (!\is_string($as)) { + throw new \LogicException(\sprintf('%s() requires the "as" user be a string or %s.', __METHOD__, UserInterface::class)); + } + + Assert::that($token->getUserIdentifier()) + ->is($as, 'Expected to be authenticated as "{expected}" but authenticated as "{actual}".') + ; + + return $this; + } + + /** + * @return static + */ + public function assertNotAuthenticated(): self + { + Assert::that($token = $this->securityToken()) + ->isNull('Expected to NOT be authenticated but authenticated as "{actual}".', [ + 'actual' => $token ? $token->getUserIdentifier() : null, + ]) + ; + + return $this; + } + final public function profile(): Profile { if (!$profile = $this->client()->getProfile()) { @@ -428,4 +483,15 @@ protected function useParameters(): array })), ]; } + + private function securityToken(): ?TokenInterface + { + $container = $this->client()->getContainer(); + + if (!$container->has('security.token_storage')) { + throw new \LogicException('Security not available/enabled.'); + } + + return $container->get('security.token_storage')->getToken(); + } } diff --git a/tests/KernelBrowserTests.php b/tests/KernelBrowserTests.php index 51d1286..7925c2a 100644 --- a/tests/KernelBrowserTests.php +++ b/tests/KernelBrowserTests.php @@ -83,6 +83,45 @@ public function can_act_as_user_with_foundry_proxy(): void ; } + /** + * @test + */ + public function can_make_authentication_assertions(): void + { + // todo remove this requirement in foundry + Factory::boot(new Configuration()); + + $username = 'kevin'; + $user = new InMemoryUser('kevin', 'pass'); + $factory = factory(InMemoryUser::class, ['username' => 'kevin', 'password' => 'pass']); + $proxy = factory(InMemoryUser::class)->create(['username' => 'kevin', 'password' => 'pass']); + + $this->browser() + ->assertNotAuthenticated() + ->actingAs($user) + ->assertAuthenticated() + ->assertAuthenticated($username) + ->assertAuthenticated($user) + ->assertAuthenticated($factory) + ->assertAuthenticated($proxy) + ->visit('/user') + ->assertAuthenticated() + ->assertAuthenticated($username) + ; + } + + /** + * @test + */ + public function can_check_if_not_authenticated_after_request(): void + { + $this->browser() + ->visit('/page1') + ->assertNotAuthenticated() + ->assertSeeIn('a', 'a link') + ; + } + /** * @test */