diff --git a/.gitignore b/.gitignore index bddab1f..832aa01 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ composer.lock .vscode cache file.db +.phpunit.result.cache diff --git a/src/Helpers/Cookie.php b/src/Helpers/Cookie.php index 8d14a25..2a681ca 100644 --- a/src/Helpers/Cookie.php +++ b/src/Helpers/Cookie.php @@ -13,6 +13,29 @@ class Cookie public const PREFIX_SECURE = "secure"; public const PREFIX_HOST = "host"; + /** + * Check if a cookie exists + * + * @param Context $context The context object containing request information + * @param string $name The name of the cookie to check + * @param string|null $value Optional value to check against + * @param string $prefix Optional prefix for the cookie name + * @return bool True if the cookie exists (and optionally matches the value), false otherwise + */ + public static function hasCookie( + Context $context, + string $name, + ?string $value = null, + string $prefix = "" + ): bool { + $cookies = self::parseCookies($context->req->header("Cookie") ?? ""); + $fullName = self::getPrefixedName($name, $prefix); + $cookieValue = $cookies[$fullName] ?? null; + + return $cookieValue !== null && + ($value === null || $cookieValue === $value); + } + /** * Get all cookies or a specific cookie. * @@ -26,19 +49,15 @@ class Cookie ?string $name = null, ?string $prefix = null ): array|string|null { - $cookies = $context->req->header("Cookie"); - if (!$cookies) { - return $name ? null : []; - } - - $parsedCookies = self::parseCookies($cookies); + $cookies = self::parseCookies($context->req->header("Cookie") ?? ""); if ($name === null) { - return $parsedCookies; + return $cookies; } $fullName = self::getPrefixedName($name, $prefix); - return $parsedCookies[$fullName] ?? null; + + return $cookies[$fullName] ?? null; } /** @@ -72,9 +91,17 @@ class Cookie string $name, array $options = [] ): ?string { - $value = self::getCookie($context, $name); + $prefix = $options["prefix"] ?? ""; + $value = self::getCookie($context, $name, $prefix); + + if ($value === null) { + return null; + } + $options["expires"] = 1; + $options["path"] = $options["path"] ?? "/"; self::setCookie($context, $name, "", $options); + return $value; } @@ -93,25 +120,8 @@ class Cookie ?string $name = null, ?string $prefix = null ): mixed { - if ($name === null) { - $allCookies = self::getCookie($context); - $signedCookies = []; - - foreach ($allCookies as $cookieName => $cookieValue) { - $verifiedValue = self::verifySignedCookie( - $cookieValue, - $secret - ); - - if ($verifiedValue !== false) { - $signedCookies[$cookieName] = $verifiedValue; - } - } - - return $signedCookies; - } - $value = self::getCookie($context, $name, $prefix); + if ($value === null) { return null; } @@ -137,9 +147,52 @@ class Cookie ): void { $signature = self::sign($value, $secret); $signedValue = $value . "." . $signature; + self::setCookie($context, $name, $signedValue, $options); } + /** + * Refresh a cookie's expiration time if it exists. + * + * @param Context $context The context object for setting the response header + * @param string $name The name of the cookie to refresh + * @param array $options Additional options for the cookie + * @return bool True if the cookie was refreshed, false if it doesn't exist + */ + public static function refreshCookie( + Context $context, + string $name, + array $options = [] + ): bool { + $prefix = $options["prefix"] ?? ""; + $value = self::getCookie($context, $name, $prefix); + + if ($value === null) { + return false; + } + + self::setCookie($context, $name, $value, $options); + + return true; + } + + /** + * Clear all cookies. + * + * @param Context $context The context object for setting the response header + */ + public static function clearAllCookies(Context $context): void + { + $cookies = self::getCookie($context); + + foreach ($cookies as $name => $value) { + $context->header( + "Set-Cookie", + $name . "=; Expires=Thu, 01 Jan 1970 00:00:01 GMT" + ); + } + } + /** * Parse a cookie string into an associative array. * @@ -149,13 +202,19 @@ class Cookie private static function parseCookies(string $cookieString): array { $cookies = []; + if (empty($cookieString)) { + return $cookies; + } + $pairs = explode("; ", $cookieString); + foreach ($pairs as $pair) { $parts = explode("=", $pair, 2); if (count($parts) === 2) { $cookies[urldecode($parts[0])] = urldecode($parts[1]); } } + return $cookies; } @@ -208,10 +267,6 @@ class Cookie $parts[] = "SameSite=" . $options["sameSite"]; } - if (isset($options["partitioned"]) && $options["partitioned"]) { - $parts[] = "Partitioned"; - } - return implode("; ", $parts); } @@ -258,11 +313,13 @@ class Cookie string $secret ): string|false { $parts = explode(".", $value, 2); + if (count($parts) !== 2) { return false; } list($value, $signature) = $parts; + $expectedSignature = self::sign($value, $secret); if (!hash_equals($signature, $expectedSignature)) { diff --git a/tests/Helpers/CookieTest.php b/tests/Helpers/CookieTest.php new file mode 100644 index 0000000..5f14087 --- /dev/null +++ b/tests/Helpers/CookieTest.php @@ -0,0 +1,175 @@ +request = $this->createMock(ServerRequestInterface::class); + $this->response = new Response(); + $this->context = new TestContext($this->request, $this->response); + } + + private function setRequestCookie(string $cookieString): void + { + $this->request + ->method("getHeader") + ->with("Cookie") + ->willReturn([$cookieString]); + } + + public function testHasCookie() + { + $this->setRequestCookie("test_cookie=value; other_cookie=other_value"); + + $this->assertTrue(Cookie::hasCookie($this->context, "test_cookie")); + $this->assertFalse( + Cookie::hasCookie($this->context, "non_existent_cookie") + ); + } + + public function testGetCookie() + { + $this->setRequestCookie("test_cookie=value; other_cookie=other_value"); + + $this->assertEquals( + "value", + Cookie::getCookie($this->context, "test_cookie") + ); + $this->assertNull( + Cookie::getCookie($this->context, "non_existent_cookie") + ); + } + + public function testSetCookie() + { + Cookie::setCookie($this->context, "test_cookie", "new_value"); + + $setCookieHeaders = $this->context + ->getResponse() + ->getHeader("Set-Cookie"); + $this->assertCount(1, $setCookieHeaders); + $this->assertStringContainsString( + "test_cookie=new_value", + $setCookieHeaders[0] + ); + } + + public function testDeleteCookie() + { + $this->setRequestCookie("test_cookie=value; other_cookie=other_value"); + + $deletedValue = Cookie::deleteCookie($this->context, "test_cookie"); + + $this->assertEquals("value", $deletedValue); + $setCookieHeaders = $this->context + ->getResponse() + ->getHeader("Set-Cookie"); + $this->assertCount(1, $setCookieHeaders); + $this->assertStringContainsString("test_cookie=", $setCookieHeaders[0]); + $this->assertStringContainsString("Expires=", $setCookieHeaders[0]); + } + + public function testGetSignedCookie() + { + $secret = "test_secret"; + $value = "test_value"; + $signature = hash_hmac("sha256", $value, $secret); + $signedValue = $value . "." . $signature; + + $this->setRequestCookie("signed_cookie=$signedValue"); + + $this->assertEquals( + $value, + Cookie::getSignedCookie($this->context, $secret, "signed_cookie") + ); + } + + public function testSetSignedCookie() + { + $secret = "test_secret"; + $name = "signed_cookie"; + $value = "test_value"; + + Cookie::setSignedCookie($this->context, $name, $value, $secret); + + $setCookieHeaders = $this->context + ->getResponse() + ->getHeader("Set-Cookie"); + $this->assertCount(1, $setCookieHeaders); + $this->assertStringContainsString("$name=", $setCookieHeaders[0]); + } + + public function testRefreshCookie() + { + $this->setRequestCookie("test_cookie=old_value"); + + $refreshed = Cookie::refreshCookie($this->context, "test_cookie"); + + $this->assertTrue($refreshed); + $setCookieHeaders = $this->context + ->getResponse() + ->getHeader("Set-Cookie"); + $this->assertCount(1, $setCookieHeaders); + $this->assertStringContainsString( + "test_cookie=old_value", + $setCookieHeaders[0] + ); + } + + public function testClearAllCookies() + { + $this->setRequestCookie("cookie1=value1; cookie2=value2"); + + Cookie::clearAllCookies($this->context); + + $setCookieHeaders = $this->context + ->getResponse() + ->getHeader("Set-Cookie"); + $this->assertCount(2, $setCookieHeaders); + $this->assertStringContainsString( + "cookie1=; Expires=", + $setCookieHeaders[0] + ); + $this->assertStringContainsString( + "cookie2=; Expires=", + $setCookieHeaders[1] + ); + } +} + +class TestContext extends Context +{ + private $response; + + public function __construct( + ServerRequestInterface $request, + ResponseInterface $response + ) { + parent::__construct($request, [], ""); + $this->response = $response; + } + + public function getResponse(): ResponseInterface + { + return $this->response; + } + + public function header(string $name, string $value): self + { + $this->response = $this->response->withAddedHeader($name, $value); + return $this; + } +}