From 549afaab177804c157d7c472af5f4bb0876317f3 Mon Sep 17 00:00:00 2001 From: Nikita Safonov Date: Sat, 4 Jan 2020 23:55:15 +0300 Subject: [PATCH] [HttpFoundation] added withers to Cookie class --- .../Component/HttpFoundation/CHANGELOG.md | 3 + .../Component/HttpFoundation/Cookie.php | 127 ++++++++++++++++-- .../HttpFoundation/Tests/CookieTest.php | 110 ++++++++++++++- 3 files changed, 227 insertions(+), 13 deletions(-) diff --git a/src/Symfony/Component/HttpFoundation/CHANGELOG.md b/src/Symfony/Component/HttpFoundation/CHANGELOG.md index 9b50cf7fa6..774296ad75 100644 --- a/src/Symfony/Component/HttpFoundation/CHANGELOG.md +++ b/src/Symfony/Component/HttpFoundation/CHANGELOG.md @@ -4,6 +4,9 @@ CHANGELOG 5.1.0 ----- + * added `Cookie::withValue`, `Cookie::withDomain`, `Cookie::withExpires`, + `Cookie::withPath`, `Cookie::withSecure`, `Cookie::withHttpOnly`, + `Cookie::withRaw`, `Cookie::withSameSite` * Deprecate `Response::create()`, `JsonResponse::create()`, `RedirectResponse::create()`, and `StreamedResponse::create()` methods (use `__construct()` instead) diff --git a/src/Symfony/Component/HttpFoundation/Cookie.php b/src/Symfony/Component/HttpFoundation/Cookie.php index fc711ee663..fa03ac3b9f 100644 --- a/src/Symfony/Component/HttpFoundation/Cookie.php +++ b/src/Symfony/Component/HttpFoundation/Cookie.php @@ -99,6 +99,52 @@ class Cookie throw new \InvalidArgumentException('The cookie name cannot be empty.'); } + $this->name = $name; + $this->value = $value; + $this->domain = $domain; + $this->expire = $this->withExpires($expire)->expire; + $this->path = empty($path) ? '/' : $path; + $this->secure = $secure; + $this->httpOnly = $httpOnly; + $this->raw = $raw; + $this->sameSite = $this->withSameSite($sameSite)->sameSite; + } + + /** + * Creates a cookie copy with a new value. + * + * @return static + */ + public function withValue(?string $value): self + { + $cookie = clone $this; + $cookie->value = $value; + + return $cookie; + } + + /** + * Creates a cookie copy with a new domain that the cookie is available to. + * + * @return static + */ + public function withDomain(?string $domain): self + { + $cookie = clone $this; + $cookie->domain = $domain; + + return $cookie; + } + + /** + * Creates a cookie copy with a new time the cookie expires. + * + * @param int|string|\DateTimeInterface $expire + * + * @return static + */ + public function withExpires($expire = 0): self + { // convert expiration time to a Unix timestamp if ($expire instanceof \DateTimeInterface) { $expire = $expire->format('U'); @@ -110,15 +156,75 @@ class Cookie } } - $this->name = $name; - $this->value = $value; - $this->domain = $domain; - $this->expire = 0 < $expire ? (int) $expire : 0; - $this->path = empty($path) ? '/' : $path; - $this->secure = $secure; - $this->httpOnly = $httpOnly; - $this->raw = $raw; + $cookie = clone $this; + $cookie->expire = 0 < $expire ? (int) $expire : 0; + return $cookie; + } + + /** + * Creates a cookie copy with a new path on the server in which the cookie will be available on. + * + * @return static + */ + public function withPath(string $path): self + { + $cookie = clone $this; + $cookie->path = '' === $path ? '/' : $path; + + return $cookie; + } + + /** + * Creates a cookie copy that only be transmitted over a secure HTTPS connection from the client. + * + * @return static + */ + public function withSecure(bool $secure = true): self + { + $cookie = clone $this; + $cookie->secure = $secure; + + return $cookie; + } + + /** + * Creates a cookie copy that be accessible only through the HTTP protocol. + * + * @return static + */ + public function withHttpOnly(bool $httpOnly = true): self + { + $cookie = clone $this; + $cookie->httpOnly = $httpOnly; + + return $cookie; + } + + /** + * Creates a cookie copy that uses no url encoding. + * + * @return static + */ + public function withRaw(bool $raw = true): self + { + if ($raw && false !== strpbrk($this->name, self::$reservedCharsList)) { + throw new \InvalidArgumentException(sprintf('The cookie name "%s" contains invalid characters.', $this->name)); + } + + $cookie = clone $this; + $cookie->raw = $raw; + + return $cookie; + } + + /** + * Creates a cookie copy with SameSite attribute. + * + * @return static + */ + public function withSameSite(?string $sameSite): self + { if ('' === $sameSite) { $sameSite = null; } elseif (null !== $sameSite) { @@ -129,7 +235,10 @@ class Cookie throw new \InvalidArgumentException('The "sameSite" parameter value is not valid.'); } - $this->sameSite = $sameSite; + $cookie = clone $this; + $cookie->sameSite = $sameSite; + + return $cookie; } /** diff --git a/src/Symfony/Component/HttpFoundation/Tests/CookieTest.php b/src/Symfony/Component/HttpFoundation/Tests/CookieTest.php index 55287e082d..ef9c13e4c4 100644 --- a/src/Symfony/Component/HttpFoundation/Tests/CookieTest.php +++ b/src/Symfony/Component/HttpFoundation/Tests/CookieTest.php @@ -47,6 +47,15 @@ class CookieTest extends TestCase Cookie::create($name, null, 0, null, null, null, false, true); } + /** + * @dataProvider namesWithSpecialCharacters + */ + public function testWithRawThrowsExceptionIfCookieNameContainsSpecialCharacters($name) + { + $this->expectException('InvalidArgumentException'); + Cookie::create($name)->withRaw(); + } + /** * @dataProvider namesWithSpecialCharacters */ @@ -72,6 +81,10 @@ class CookieTest extends TestCase $cookie = Cookie::create('foo', 'bar', -100); $this->assertSame(0, $cookie->getExpiresTime()); + + $cookie = Cookie::create('foo', 'bar')->withExpires(-100); + + $this->assertSame(0, $cookie->getExpiresTime()); } public function testGetValue() @@ -98,6 +111,10 @@ class CookieTest extends TestCase $cookie = Cookie::create('foo', 'bar', $expire = time() + 3600); $this->assertEquals($expire, $cookie->getExpiresTime(), '->getExpiresTime() returns the expire date'); + + $cookie = Cookie::create('foo')->withExpires($expire = time() + 3600); + + $this->assertEquals($expire, $cookie->getExpiresTime(), '->getExpiresTime() returns the expire date'); } public function testGetExpiresTimeIsCastToInt() @@ -105,6 +122,10 @@ class CookieTest extends TestCase $cookie = Cookie::create('foo', 'bar', 3600.9); $this->assertSame(3600, $cookie->getExpiresTime(), '->getExpiresTime() returns the expire date as an integer'); + + $cookie = Cookie::create('foo')->withExpires(3600.6); + + $this->assertSame(3600, $cookie->getExpiresTime(), '->getExpiresTime() returns the expire date as an integer'); } public function testConstructorWithDateTime() @@ -113,6 +134,10 @@ class CookieTest extends TestCase $cookie = Cookie::create('foo', 'bar', $expire); $this->assertEquals($expire->format('U'), $cookie->getExpiresTime(), '->getExpiresTime() returns the expire date'); + + $cookie = Cookie::create('foo')->withExpires($expire); + + $this->assertEquals($expire->format('U'), $cookie->getExpiresTime(), '->getExpiresTime() returns the expire date'); } public function testConstructorWithDateTimeImmutable() @@ -121,6 +146,10 @@ class CookieTest extends TestCase $cookie = Cookie::create('foo', 'bar', $expire); $this->assertEquals($expire->format('U'), $cookie->getExpiresTime(), '->getExpiresTime() returns the expire date'); + + $cookie = Cookie::create('foo')->withValue('bar')->withExpires($expire); + + $this->assertEquals($expire->format('U'), $cookie->getExpiresTime(), '->getExpiresTime() returns the expire date'); } public function testGetExpiresTimeWithStringValue() @@ -130,6 +159,10 @@ class CookieTest extends TestCase $expire = strtotime($value); $this->assertEqualsWithDelta($expire, $cookie->getExpiresTime(), 1, '->getExpiresTime() returns the expire date'); + + $cookie = Cookie::create('foo')->withValue('bar')->withExpires($value); + + $this->assertEqualsWithDelta($expire, $cookie->getExpiresTime(), 1, '->getExpiresTime() returns the expire date'); } public function testGetDomain() @@ -137,6 +170,10 @@ class CookieTest extends TestCase $cookie = Cookie::create('foo', 'bar', 0, '/', '.myfoodomain.com'); $this->assertEquals('.myfoodomain.com', $cookie->getDomain(), '->getDomain() returns the domain name on which the cookie is valid'); + + $cookie = Cookie::create('foo')->withDomain('.mybardomain.com'); + + $this->assertEquals('.mybardomain.com', $cookie->getDomain(), '->getDomain() returns the domain name on which the cookie is valid'); } public function testIsSecure() @@ -144,6 +181,10 @@ class CookieTest extends TestCase $cookie = Cookie::create('foo', 'bar', 0, '/', '.myfoodomain.com', true); $this->assertTrue($cookie->isSecure(), '->isSecure() returns whether the cookie is transmitted over HTTPS'); + + $cookie = Cookie::create('foo')->withSecure(true); + + $this->assertTrue($cookie->isSecure(), '->isSecure() returns whether the cookie is transmitted over HTTPS'); } public function testIsHttpOnly() @@ -151,6 +192,10 @@ class CookieTest extends TestCase $cookie = Cookie::create('foo', 'bar', 0, '/', '.myfoodomain.com', false, true); $this->assertTrue($cookie->isHttpOnly(), '->isHttpOnly() returns whether the cookie is only transmitted over HTTP'); + + $cookie = Cookie::create('foo')->withHttpOnly(true); + + $this->assertTrue($cookie->isHttpOnly(), '->isHttpOnly() returns whether the cookie is only transmitted over HTTP'); } public function testCookieIsNotCleared() @@ -158,6 +203,10 @@ class CookieTest extends TestCase $cookie = Cookie::create('foo', 'bar', time() + 3600 * 24); $this->assertFalse($cookie->isCleared(), '->isCleared() returns false if the cookie did not expire yet'); + + $cookie = Cookie::create('foo')->withExpires(time() + 3600 * 24); + + $this->assertFalse($cookie->isCleared(), '->isCleared() returns false if the cookie did not expire yet'); } public function testCookieIsCleared() @@ -166,6 +215,10 @@ class CookieTest extends TestCase $this->assertTrue($cookie->isCleared(), '->isCleared() returns true if the cookie has expired'); + $cookie = Cookie::create('foo')->withExpires(time() - 20); + + $this->assertTrue($cookie->isCleared(), '->isCleared() returns true if the cookie has expired'); + $cookie = Cookie::create('foo', 'bar'); $this->assertFalse($cookie->isCleared()); @@ -177,21 +230,55 @@ class CookieTest extends TestCase $cookie = Cookie::create('foo', 'bar', -1); $this->assertFalse($cookie->isCleared()); + + $cookie = Cookie::create('foo')->withExpires(-1); + + $this->assertFalse($cookie->isCleared()); } public function testToString() { + $expected = 'foo=bar; expires=Fri, 20-May-2011 15:25:52 GMT; Max-Age=0; path=/; domain=.myfoodomain.com; secure; httponly'; $cookie = Cookie::create('foo', 'bar', $expire = strtotime('Fri, 20-May-2011 15:25:52 GMT'), '/', '.myfoodomain.com', true, true, false, null); - $this->assertEquals('foo=bar; expires=Fri, 20-May-2011 15:25:52 GMT; Max-Age=0; path=/; domain=.myfoodomain.com; secure; httponly', (string) $cookie, '->__toString() returns string representation of the cookie'); + $this->assertEquals($expected, (string) $cookie, '->__toString() returns string representation of the cookie'); + $cookie = Cookie::create('foo') + ->withValue('bar') + ->withExpires(strtotime('Fri, 20-May-2011 15:25:52 GMT')) + ->withDomain('.myfoodomain.com') + ->withSecure(true) + ->withSameSite(null); + $this->assertEquals($expected, (string) $cookie, '->__toString() returns string representation of the cookie'); + + $expected = 'foo=bar%20with%20white%20spaces; expires=Fri, 20-May-2011 15:25:52 GMT; Max-Age=0; path=/; domain=.myfoodomain.com; secure; httponly'; $cookie = Cookie::create('foo', 'bar with white spaces', strtotime('Fri, 20-May-2011 15:25:52 GMT'), '/', '.myfoodomain.com', true, true, false, null); - $this->assertEquals('foo=bar%20with%20white%20spaces; expires=Fri, 20-May-2011 15:25:52 GMT; Max-Age=0; path=/; domain=.myfoodomain.com; secure; httponly', (string) $cookie, '->__toString() encodes the value of the cookie according to RFC 3986 (white space = %20)'); + $this->assertEquals($expected, (string) $cookie, '->__toString() encodes the value of the cookie according to RFC 3986 (white space = %20)'); + $cookie = Cookie::create('foo') + ->withValue('bar with white spaces') + ->withExpires(strtotime('Fri, 20-May-2011 15:25:52 GMT')) + ->withDomain('.myfoodomain.com') + ->withSecure(true) + ->withSameSite(null); + $this->assertEquals($expected, (string) $cookie, '->__toString() encodes the value of the cookie according to RFC 3986 (white space = %20)'); + + $expected = 'foo=deleted; expires='.gmdate('D, d-M-Y H:i:s T', $expire = time() - 31536001).'; Max-Age=0; path=/admin/; domain=.myfoodomain.com; httponly'; $cookie = Cookie::create('foo', null, 1, '/admin/', '.myfoodomain.com', false, true, false, null); - $this->assertEquals('foo=deleted; expires='.gmdate('D, d-M-Y H:i:s T', $expire = time() - 31536001).'; Max-Age=0; path=/admin/; domain=.myfoodomain.com; httponly', (string) $cookie, '->__toString() returns string representation of a cleared cookie if value is NULL'); + $this->assertEquals($expected, (string) $cookie, '->__toString() returns string representation of a cleared cookie if value is NULL'); + $cookie = Cookie::create('foo') + ->withExpires(1) + ->withPath('/admin/') + ->withDomain('.myfoodomain.com') + ->withSameSite(null); + $this->assertEquals($expected, (string) $cookie, '->__toString() returns string representation of a cleared cookie if value is NULL'); + + $expected = 'foo=bar; path=/; httponly; samesite=lax'; $cookie = Cookie::create('foo', 'bar'); - $this->assertEquals('foo=bar; path=/; httponly; samesite=lax', (string) $cookie); + $this->assertEquals($expected, (string) $cookie); + + $cookie = Cookie::create('foo')->withValue('bar'); + $this->assertEquals($expected, (string) $cookie); } public function testRawCookie() @@ -200,9 +287,21 @@ class CookieTest extends TestCase $this->assertFalse($cookie->isRaw()); $this->assertEquals('foo=b%20a%20r; path=/', (string) $cookie); + $cookie = Cookie::create('test')->withValue('t e s t')->withHttpOnly(false)->withSameSite(null); + $this->assertFalse($cookie->isRaw()); + $this->assertEquals('test=t%20e%20s%20t; path=/', (string) $cookie); + $cookie = Cookie::create('foo', 'b+a+r', 0, '/', null, false, false, true, null); $this->assertTrue($cookie->isRaw()); $this->assertEquals('foo=b+a+r; path=/', (string) $cookie); + + $cookie = Cookie::create('foo') + ->withValue('t+e+s+t') + ->withHttpOnly(false) + ->withRaw(true) + ->withSameSite(null); + $this->assertTrue($cookie->isRaw()); + $this->assertEquals('foo=t+e+s+t; path=/', (string) $cookie); } public function testGetMaxAge() @@ -245,6 +344,9 @@ class CookieTest extends TestCase $cookie = new Cookie('foo', 'bar', 0, '/', null, false, true, false, ''); $this->assertNull($cookie->getSameSite()); + + $cookie = Cookie::create('foo')->withSameSite('Lax'); + $this->assertEquals('lax', $cookie->getSameSite()); } public function testSetSecureDefault()