From f0871be69efec9f19bd4921c0da9a46c36052f29 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Thu, 11 Jun 2026 06:41:07 +0200 Subject: [PATCH] feat: Cookie handling domain object model Models a CookieParser, CookieList, Cookie interface and a strict Cookie implementation We only strictly accept string values which are allowed by the cookie related RFCs and practical browsers While PHP's builtin parsing is lax and surprises users with what may come from invalid content, we throw. If you need to handle non-conforming input when creating cookies, best build a wrapper/decorator around StrictCookie (implementing Cookie interface) or build your own Cookie interface implementation --- .horde.yml | 2 +- composer.json | 4 +- src/Cookie.php | 93 ++++++++ src/CookieList.php | 90 ++++++++ src/CookieParser.php | 302 ++++++++++++++++++++++++++ src/SameSite.php | 43 ++++ src/StrictCookie.php | 317 +++++++++++++++++++++++++++ test/Unit/CookieListTest.php | 112 ++++++++++ test/Unit/CookieParserTest.php | 376 ++++++++++++++++++++++++++++++++ test/Unit/SameSiteTest.php | 71 ++++++ test/Unit/StrictCookieTest.php | 379 +++++++++++++++++++++++++++++++++ 11 files changed, 1786 insertions(+), 3 deletions(-) create mode 100644 src/Cookie.php create mode 100644 src/CookieList.php create mode 100644 src/CookieParser.php create mode 100644 src/SameSite.php create mode 100644 src/StrictCookie.php create mode 100644 test/Unit/CookieListTest.php create mode 100644 test/Unit/CookieParserTest.php create mode 100644 test/Unit/SameSiteTest.php create mode 100644 test/Unit/StrictCookieTest.php diff --git a/.horde.yml b/.horde.yml index 56c60f1..c73858c 100644 --- a/.horde.yml +++ b/.horde.yml @@ -34,7 +34,7 @@ provides: psr/http-client-implementation: ^1.0.3 dependencies: required: - php: ^7.4 || ^8 + php: ^8.1 composer: horde/exception: ^3 horde/support: ^3 diff --git a/composer.json b/composer.json index 41fdf1f..eb7f017 100644 --- a/composer.json +++ b/composer.json @@ -16,10 +16,10 @@ "role": "lead" } ], - "time": "2026-05-25", + "time": "2026-06-10", "repositories": [], "require": { - "php": "^7.4 || ^8", + "php": "^8.1", "horde/exception": "^3 || dev-FRAMEWORK_6_0", "horde/support": "^3 || dev-FRAMEWORK_6_0", "psr/http-message": "^2", diff --git a/src/Cookie.php b/src/Cookie.php new file mode 100644 index 0000000..6a85566 --- /dev/null +++ b/src/Cookie.php @@ -0,0 +1,93 @@ + value), and to parse a raw + * Cookie request header string into the same shape via + * {@see fromCookieHeader()}. + * + * Read-only. The instance is the request side of the cookie story; if + * you want to emit cookies on a response, build {@see Cookie} instances + * and use the server-side glue (`Horde\Http\Server\Cookies::with()`). + * + * Repeated names: last-wins, matching the convention browsers use when + * emitting cookies in path-most-specific order. + */ +final class CookieList +{ + /** @param array $cookies */ + public function __construct(private readonly array $cookies) {} + + /** + * Build from PSR-7's getCookieParams() output. Pass-through + * constructor; provided for symmetry with fromCookieHeader(). + * + * @param array $params + */ + public static function fromCookieParams(array $params): self + { + return new self($params); + } + + /** + * Parse a Cookie request header value into a list. + * + * Empty/whitespace-only headers produce an empty list. + */ + public static function fromCookieHeader(string $header): self + { + return new self(CookieParser::parseCookieHeader($header)); + } + + public function get(string $name): ?string + { + return $this->cookies[$name] ?? null; + } + + public function has(string $name): bool + { + return array_key_exists($name, $this->cookies); + } + + /** + * @return list + */ + public function names(): array + { + return array_keys($this->cookies); + } + + /** + * @return array + */ + public function toArray(): array + { + return $this->cookies; + } + + public function count(): int + { + return count($this->cookies); + } +} diff --git a/src/CookieParser.php b/src/CookieParser.php new file mode 100644 index 0000000..dcfbc7b --- /dev/null +++ b/src/CookieParser.php @@ -0,0 +1,302 @@ +name() . '=' . $cookie->value()]; + + $expires = $cookie->expires(); + if ($expires !== null) { + $parts[] = 'Expires=' . $expires->setTimezone(new DateTimeZone('GMT')) + ->format(self::EXPIRES_FORMAT); + } + + $maxAge = $cookie->maxAge(); + if ($maxAge !== null) { + $parts[] = 'Max-Age=' . $maxAge; + } + + $domain = $cookie->domain(); + if ($domain !== null && $domain !== '') { + $parts[] = 'Domain=' . $domain; + } + + // Path always emitted; defaults to "/" and is meaningful even then. + $parts[] = 'Path=' . $cookie->path(); + + if ($cookie->secure()) { + $parts[] = 'Secure'; + } + + if ($cookie->httpOnly()) { + $parts[] = 'HttpOnly'; + } + + // SameSite always emitted; the wire token is the enum value. + $parts[] = 'SameSite=' . $cookie->sameSite()->value; + + return implode('; ', $parts); + } + + /** + * Format a {@see Cookie} as a single name=value pair for use in + * a Cookie request header. No attributes; just the pair. + */ + public static function formatCookie(Cookie $cookie): string + { + return $cookie->name() . '=' . $cookie->value(); + } + + /** + * Parse a single Set-Cookie header value into a {@see StrictCookie}. + * + * @throws InvalidArgumentException If the header is malformed or the + * resulting cookie violates strict + * validation. + */ + public static function parseSetCookie(string $header): Cookie + { + $segments = self::splitAttributes($header); + if ($segments === []) { + throw new InvalidArgumentException('Set-Cookie header is empty.'); + } + + // First segment is name=value. + $first = array_shift($segments); + $eq = strpos($first, '='); + if ($eq === false) { + throw new InvalidArgumentException(sprintf( + 'Set-Cookie header missing name=value pair: "%s".', + $header, + )); + } + $name = trim(substr($first, 0, $eq)); + $value = self::stripQuotes(trim(substr($first, $eq + 1))); + + $expires = null; + $maxAge = null; + $domain = null; + $path = '/'; + $secure = false; + $httpOnly = false; + $sameSite = SameSite::Lax; + + foreach ($segments as $segment) { + $segment = trim($segment); + if ($segment === '') { + continue; + } + $eq = strpos($segment, '='); + if ($eq === false) { + $attrName = strtolower($segment); + $attrValue = ''; + } else { + $attrName = strtolower(trim(substr($segment, 0, $eq))); + $attrValue = trim(substr($segment, $eq + 1)); + } + + switch ($attrName) { + case 'expires': + $parsed = self::parseExpires($attrValue); + if ($parsed !== null) { + $expires = $parsed; + } + break; + + case 'max-age': + if (preg_match('/\A-?\d+\z/', $attrValue) === 1) { + $maxAge = (int) $attrValue; + } + break; + + case 'domain': + // RFC 6265bis: a leading dot in the attribute value is + // dropped by the user agent; we drop it on parse so the + // round trip lands on the canonical form. + if ($attrValue !== '') { + $domain = ltrim($attrValue, '.'); + } + break; + + case 'path': + if ($attrValue !== '') { + $path = $attrValue; + } + break; + + case 'secure': + $secure = true; + break; + + case 'httponly': + $httpOnly = true; + break; + + case 'samesite': + $resolved = self::parseSameSite($attrValue); + if ($resolved !== null) { + $sameSite = $resolved; + } + break; + + // Unknown attributes are ignored per RFC 6265 section 5.2. + } + } + + return new StrictCookie( + cookieName: $name, + cookieValue: $value, + cookieExpires: $expires, + cookieMaxAge: $maxAge, + cookieDomain: $domain, + cookiePath: $path, + cookieSecure: $secure, + cookieHttpOnly: $httpOnly, + cookieSameSite: $sameSite, + ); + } + + /** + * Parse a Cookie request header into name=>value pairs. + * + * Browsers transmit cookies as `name1=value1; name2=value2; ...`. + * Repeated names: last-wins, matching the convention browsers use. + * + * @return array + */ + public static function parseCookieHeader(string $header): array + { + $pairs = []; + if (trim($header) === '') { + return $pairs; + } + foreach (explode(';', $header) as $segment) { + $segment = trim($segment); + if ($segment === '') { + continue; + } + $eq = strpos($segment, '='); + if ($eq === false) { + // Cookie request header pairs without "=" are technically + // valid (the value is empty); accept as such. + $pairs[$segment] = ''; + continue; + } + $name = trim(substr($segment, 0, $eq)); + $value = self::stripQuotes(trim(substr($segment, $eq + 1))); + $pairs[$name] = $value; + } + return $pairs; + } + + /** + * Split a Set-Cookie header on attribute boundaries. + * + * Returns the name=value pair as the first element and one segment + * per attribute thereafter. Empty segments are filtered out. + * + * @return list + */ + private static function splitAttributes(string $header): array + { + $segments = []; + foreach (explode(';', $header) as $segment) { + $segment = trim($segment); + if ($segment !== '') { + $segments[] = $segment; + } + } + return $segments; + } + + private static function stripQuotes(string $value): string + { + $len = strlen($value); + if ($len >= 2 && $value[0] === '"' && $value[$len - 1] === '"') { + return substr($value, 1, $len - 2); + } + return $value; + } + + private static function parseExpires(string $value): ?DateTimeImmutable + { + if ($value === '') { + return null; + } + // DateTimeImmutable understands the IMF-fixdate format and most + // historical variants browsers may emit. Failure returns null, + // which we treat as "ignore the attribute" rather than "reject + // the cookie" : that matches user-agent leniency on Expires. + try { + return new DateTimeImmutable($value); + } catch (Exception) { + return null; + } + } + + private static function parseSameSite(string $value): ?SameSite + { + if ($value === '') { + return null; + } + // Be tolerant of casing on parse: browsers and intermediaries + // historically vary. We canonicalise to the enum cases. + return match (strtolower($value)) { + 'strict' => SameSite::Strict, + 'lax' => SameSite::Lax, + 'none' => SameSite::None, + default => null, + }; + } +} diff --git a/src/SameSite.php b/src/SameSite.php new file mode 100644 index 0000000..8fda95e --- /dev/null +++ b/src/SameSite.php @@ -0,0 +1,43 @@ +validate(); + } + + public function name(): string + { + return $this->cookieName; + } + + public function value(): string + { + return $this->cookieValue; + } + + public function expires(): ?DateTimeImmutable + { + return $this->cookieExpires; + } + + public function maxAge(): ?int + { + return $this->cookieMaxAge; + } + + public function domain(): ?string + { + return $this->cookieDomain; + } + + public function path(): string + { + return $this->cookiePath; + } + + public function secure(): bool + { + return $this->cookieSecure; + } + + public function httpOnly(): bool + { + return $this->cookieHttpOnly; + } + + public function sameSite(): SameSite + { + return $this->cookieSameSite; + } + + public function withValue(string $value): Cookie + { + if ($value === $this->cookieValue) { + return $this; + } + return new self( + $this->cookieName, + $value, + $this->cookieExpires, + $this->cookieMaxAge, + $this->cookieDomain, + $this->cookiePath, + $this->cookieSecure, + $this->cookieHttpOnly, + $this->cookieSameSite, + ); + } + + public function withExpires(?DateTimeImmutable $expires): Cookie + { + return new self( + $this->cookieName, + $this->cookieValue, + $expires, + $this->cookieMaxAge, + $this->cookieDomain, + $this->cookiePath, + $this->cookieSecure, + $this->cookieHttpOnly, + $this->cookieSameSite, + ); + } + + public function withMaxAge(?int $maxAge): Cookie + { + return new self( + $this->cookieName, + $this->cookieValue, + $this->cookieExpires, + $maxAge, + $this->cookieDomain, + $this->cookiePath, + $this->cookieSecure, + $this->cookieHttpOnly, + $this->cookieSameSite, + ); + } + + public function withDomain(?string $domain): Cookie + { + return new self( + $this->cookieName, + $this->cookieValue, + $this->cookieExpires, + $this->cookieMaxAge, + $domain, + $this->cookiePath, + $this->cookieSecure, + $this->cookieHttpOnly, + $this->cookieSameSite, + ); + } + + public function withPath(string $path): Cookie + { + return new self( + $this->cookieName, + $this->cookieValue, + $this->cookieExpires, + $this->cookieMaxAge, + $this->cookieDomain, + $path, + $this->cookieSecure, + $this->cookieHttpOnly, + $this->cookieSameSite, + ); + } + + public function withSecure(bool $secure = true): Cookie + { + if ($secure === $this->cookieSecure) { + return $this; + } + return new self( + $this->cookieName, + $this->cookieValue, + $this->cookieExpires, + $this->cookieMaxAge, + $this->cookieDomain, + $this->cookiePath, + $secure, + $this->cookieHttpOnly, + $this->cookieSameSite, + ); + } + + public function withHttpOnly(bool $httpOnly = true): Cookie + { + if ($httpOnly === $this->cookieHttpOnly) { + return $this; + } + return new self( + $this->cookieName, + $this->cookieValue, + $this->cookieExpires, + $this->cookieMaxAge, + $this->cookieDomain, + $this->cookiePath, + $this->cookieSecure, + $httpOnly, + $this->cookieSameSite, + ); + } + + public function withSameSite(SameSite $sameSite): Cookie + { + if ($sameSite === $this->cookieSameSite) { + return $this; + } + return new self( + $this->cookieName, + $this->cookieValue, + $this->cookieExpires, + $this->cookieMaxAge, + $this->cookieDomain, + $this->cookiePath, + $this->cookieSecure, + $this->cookieHttpOnly, + $sameSite, + ); + } + + public function toSetCookieHeader(): string + { + return CookieParser::formatSetCookie($this); + } + + public function toCookieHeader(): string + { + return CookieParser::formatCookie($this); + } + + public function deletion(): Cookie + { + return new self( + cookieName: $this->cookieName, + cookieValue: '', + cookieExpires: null, + cookieMaxAge: 0, + cookieDomain: $this->cookieDomain, + cookiePath: $this->cookiePath, + cookieSecure: $this->cookieSecure, + cookieHttpOnly: $this->cookieHttpOnly, + cookieSameSite: $this->cookieSameSite, + ); + } + + private function validate(): void + { + if ($this->cookieName === '') { + throw new InvalidArgumentException('Cookie name must not be empty.'); + } + if (preg_match(self::NAME_PATTERN, $this->cookieName) !== 1) { + throw new InvalidArgumentException(sprintf( + 'Cookie name "%s" contains characters that are not RFC 7230 token characters. ' + . 'Allowed: ALPHA / DIGIT / "!" / "#" / "$" / "%%" / "&" / "\'" / "*" / "+" / "-" / "." / ' + . '"^" / "_" / "`" / "|" / "~".', + $this->cookieName, + )); + } + + if (preg_match(self::VALUE_PATTERN, $this->cookieValue) !== 1) { + throw new InvalidArgumentException(sprintf( + 'Cookie value for "%s" contains octets outside the RFC 6265 cookie-octet range. ' + . 'Encode the value (URL-encoding, base64, etc.) before construction; ' + . 'StrictCookie does not perform silent encoding.', + $this->cookieName, + )); + } + + if ($this->cookieMaxAge !== null && $this->cookieMaxAge < 0) { + throw new InvalidArgumentException(sprintf( + 'Cookie Max-Age must be non-negative; got %d. Use 0 to delete the cookie.', + $this->cookieMaxAge, + )); + } + + if ($this->cookiePath === '' || $this->cookiePath[0] !== '/') { + throw new InvalidArgumentException(sprintf( + 'Cookie Path must start with "/"; got "%s".', + $this->cookiePath, + )); + } + + if ($this->cookieSameSite === SameSite::None && !$this->cookieSecure) { + throw new InvalidArgumentException( + 'Cookie with SameSite=None must also be marked Secure. ' + . 'Modern browsers reject the combination otherwise.' + ); + } + } +} diff --git a/test/Unit/CookieListTest.php b/test/Unit/CookieListTest.php new file mode 100644 index 0000000..fd21815 --- /dev/null +++ b/test/Unit/CookieListTest.php @@ -0,0 +1,112 @@ + '1', 'b' => '2']); + self::assertSame(['a' => '1', 'b' => '2'], $list->toArray()); + } + + #[Test] + public function fromCookieHeaderParsesPairs(): void + { + $list = CookieList::fromCookieHeader('a=1; b=2; c=3'); + self::assertSame('1', $list->get('a')); + self::assertSame('2', $list->get('b')); + self::assertSame('3', $list->get('c')); + } + + #[Test] + public function fromCookieHeaderEmpty(): void + { + self::assertSame([], CookieList::fromCookieHeader('')->toArray()); + self::assertSame([], CookieList::fromCookieHeader(' ')->toArray()); + } + + #[Test] + public function getReturnsNullWhenAbsent(): void + { + $list = CookieList::fromCookieParams(['a' => '1']); + self::assertNull($list->get('missing')); + } + + #[Test] + public function getReturnsValueWhenPresent(): void + { + $list = CookieList::fromCookieParams(['session' => 'abc123']); + self::assertSame('abc123', $list->get('session')); + } + + #[Test] + public function hasDistinguishesPresentFromAbsent(): void + { + $list = CookieList::fromCookieParams(['a' => '1', 'empty' => '']); + self::assertTrue($list->has('a')); + self::assertTrue($list->has('empty')); // present-but-empty + self::assertFalse($list->has('missing')); + } + + #[Test] + public function namesReturnsAllNamesInOrder(): void + { + $list = CookieList::fromCookieParams(['z' => '1', 'a' => '2', 'm' => '3']); + self::assertSame(['z', 'a', 'm'], $list->names()); + } + + #[Test] + public function toArrayReturnsUnderlyingMap(): void + { + $params = ['a' => '1', 'b' => '2']; + $list = CookieList::fromCookieParams($params); + self::assertSame($params, $list->toArray()); + } + + #[Test] + public function countReturnsNumberOfCookies(): void + { + self::assertSame(0, CookieList::fromCookieParams([])->count()); + self::assertSame(1, CookieList::fromCookieParams(['a' => '1'])->count()); + self::assertSame(3, CookieList::fromCookieParams(['a' => '1', 'b' => '2', 'c' => '3'])->count()); + } + + #[Test] + public function constructAcceptsEmptyMap(): void + { + $list = new CookieList([]); + self::assertSame([], $list->toArray()); + self::assertSame([], $list->names()); + self::assertFalse($list->has('anything')); + self::assertNull($list->get('anything')); + } + + #[Test] + public function fromCookieHeaderRepeatedNamesLastWins(): void + { + // Delegated to CookieParser; tested here for the public contract. + $list = CookieList::fromCookieHeader('a=1; a=2; a=3'); + self::assertSame('3', $list->get('a')); + } +} diff --git a/test/Unit/CookieParserTest.php b/test/Unit/CookieParserTest.php new file mode 100644 index 0000000..45476a1 --- /dev/null +++ b/test/Unit/CookieParserTest.php @@ -0,0 +1,376 @@ +deletion(); + $header = CookieParser::formatSetCookie($deletion); + + self::assertStringContainsString('session=', $header); + self::assertStringContainsString('Max-Age=0', $header); + self::assertStringContainsString('Domain=example.com', $header); + self::assertStringContainsString('Path=/horde', $header); + self::assertStringNotContainsString('Expires=', $header); + } + + #[Test] + public function formatOmitsDomainWhenNull(): void + { + $header = CookieParser::formatSetCookie( + new StrictCookie(cookieName: 'x', cookieValue: 'y'), + ); + self::assertStringNotContainsString('Domain=', $header); + } + + #[Test] + public function formatOmitsExpiresWhenNull(): void + { + $header = CookieParser::formatSetCookie( + new StrictCookie(cookieName: 'x', cookieValue: 'y'), + ); + self::assertStringNotContainsString('Expires=', $header); + } + + #[Test] + public function formatOmitsMaxAgeWhenNull(): void + { + $header = CookieParser::formatSetCookie( + new StrictCookie(cookieName: 'x', cookieValue: 'y'), + ); + self::assertStringNotContainsString('Max-Age=', $header); + } + + // --------------------------------------------------------------- + // formatCookie() (request header pair) + // --------------------------------------------------------------- + + #[Test] + public function formatCookieIsJustNameEqualsValue(): void + { + $cookie = new StrictCookie( + cookieName: 'session', + cookieValue: 'abc', + cookieDomain: 'example.com', + cookiePath: '/horde', + cookieSecure: true, + cookieHttpOnly: true, + ); + // Cookie request header carries no attributes; only the pair. + self::assertSame('session=abc', CookieParser::formatCookie($cookie)); + } + + #[Test] + public function formatCookieEmptyValue(): void + { + self::assertSame('x=', CookieParser::formatCookie( + new StrictCookie(cookieName: 'x'), + )); + } + + // --------------------------------------------------------------- + // parseSetCookie() + // --------------------------------------------------------------- + + #[Test] + public function parseMinimalSetCookie(): void + { + $cookie = CookieParser::parseSetCookie('session=abc; Path=/; SameSite=Lax'); + self::assertSame('session', $cookie->name()); + self::assertSame('abc', $cookie->value()); + self::assertSame('/', $cookie->path()); + self::assertSame(SameSite::Lax, $cookie->sameSite()); + } + + #[Test] + public function parseAllAttributes(): void + { + $header = 'Horde=token123; ' + . 'Expires=Thu, 31 Dec 2026 23:59:59 GMT; ' + . 'Max-Age=3600; ' + . 'Domain=example.com; ' + . 'Path=/horde; ' + . 'Secure; ' + . 'HttpOnly; ' + . 'SameSite=Strict'; + $cookie = CookieParser::parseSetCookie($header); + + self::assertSame('Horde', $cookie->name()); + self::assertSame('token123', $cookie->value()); + self::assertNotNull($cookie->expires()); + self::assertSame('Thu, 31 Dec 2026 23:59:59 +0000', $cookie->expires()->format('D, d M Y H:i:s O')); + self::assertSame(3600, $cookie->maxAge()); + self::assertSame('example.com', $cookie->domain()); + self::assertSame('/horde', $cookie->path()); + self::assertTrue($cookie->secure()); + self::assertTrue($cookie->httpOnly()); + self::assertSame(SameSite::Strict, $cookie->sameSite()); + } + + #[Test] + public function parseAttributeNamesAreCaseInsensitive(): void + { + $cookie = CookieParser::parseSetCookie( + 'x=y; max-age=10; PATH=/; secure; HTTPONLY; samesite=lax' + ); + self::assertSame(10, $cookie->maxAge()); + self::assertSame('/', $cookie->path()); + self::assertTrue($cookie->secure()); + self::assertTrue($cookie->httpOnly()); + self::assertSame(SameSite::Lax, $cookie->sameSite()); + } + + #[Test] + public function parseStripsLeadingDotFromDomain(): void + { + // Legacy syntax: ".example.com" was meaningful in RFC 2109; RFC + // 6265bis says drop the dot, the cookie applies to subdomains + // either way. + $cookie = CookieParser::parseSetCookie('x=y; Domain=.example.com'); + self::assertSame('example.com', $cookie->domain()); + } + + #[Test] + public function parseStripsQuotesFromValue(): void + { + // Some servers quote cookie values. RFC 6265 doesn't require us + // to interpret the quotes, but stripping them lets us round-trip + // through StrictCookie's strict octet check. + $cookie = CookieParser::parseSetCookie('session="abc123"'); + self::assertSame('abc123', $cookie->value()); + } + + #[Test] + public function parseDefaultsToLaxWhenSameSiteAbsent(): void + { + // Modern browsers default to Lax when SameSite is not specified. + // Mirror that on parse. + $cookie = CookieParser::parseSetCookie('x=y'); + self::assertSame(SameSite::Lax, $cookie->sameSite()); + } + + #[Test] + public function parseUnknownSameSiteFallsBackToLax(): void + { + // Per RFC 6265bis, unrecognised SameSite values are treated as + // if the attribute were absent : Lax in our model. + $cookie = CookieParser::parseSetCookie('x=y; SameSite=Bogus'); + self::assertSame(SameSite::Lax, $cookie->sameSite()); + } + + #[Test] + public function parseIgnoresUnknownAttributes(): void + { + $cookie = CookieParser::parseSetCookie('x=y; Path=/; Foo=bar; Bogus'); + self::assertSame('x', $cookie->name()); + self::assertSame('y', $cookie->value()); + self::assertSame('/', $cookie->path()); + } + + #[Test] + public function parseIgnoresMalformedExpires(): void + { + // Bad Expires shouldn't reject the whole cookie; user agents are + // tolerant of date parsing failures. + $cookie = CookieParser::parseSetCookie('x=y; Expires=not-a-date'); + self::assertNull($cookie->expires()); + } + + #[Test] + public function parseIgnoresMalformedMaxAge(): void + { + $cookie = CookieParser::parseSetCookie('x=y; Max-Age=lots'); + self::assertNull($cookie->maxAge()); + } + + #[Test] + public function parseEmptyHeaderRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('empty'); + CookieParser::parseSetCookie(''); + } + + #[Test] + public function parseHeaderWithoutEqualsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('name=value'); + CookieParser::parseSetCookie('justaname; Path=/'); + } + + // --------------------------------------------------------------- + // parseCookieHeader() (request side) + // --------------------------------------------------------------- + + #[Test] + public function parseCookieHeaderReturnsPairs(): void + { + $pairs = CookieParser::parseCookieHeader('a=1; b=2; c=3'); + self::assertSame(['a' => '1', 'b' => '2', 'c' => '3'], $pairs); + } + + #[Test] + public function parseCookieHeaderEmpty(): void + { + self::assertSame([], CookieParser::parseCookieHeader('')); + self::assertSame([], CookieParser::parseCookieHeader(' ')); + } + + #[Test] + public function parseCookieHeaderRepeatedNamesLastWins(): void + { + // Browsers emit cookies in path-most-specific order. Last-wins + // matches what most server frameworks do. + $pairs = CookieParser::parseCookieHeader('a=1; a=2; a=3'); + self::assertSame(['a' => '3'], $pairs); + } + + #[Test] + public function parseCookieHeaderStripsQuotes(): void + { + $pairs = CookieParser::parseCookieHeader('a="quoted"; b=plain'); + self::assertSame(['a' => 'quoted', 'b' => 'plain'], $pairs); + } + + #[Test] + public function parseCookieHeaderAcceptsValuelessPair(): void + { + // A pair with no "=" is technically valid (empty value). + $pairs = CookieParser::parseCookieHeader('flag; a=1'); + self::assertSame(['flag' => '', 'a' => '1'], $pairs); + } + + // --------------------------------------------------------------- + // Round-trip + // --------------------------------------------------------------- + + #[Test] + public function roundTripPreservesAllAttributes(): void + { + $original = new StrictCookie( + cookieName: 'Horde', + cookieValue: 'token123', + cookieExpires: new DateTimeImmutable('2026-12-31T23:59:59+00:00'), + cookieMaxAge: 3600, + cookieDomain: 'example.com', + cookiePath: '/horde', + cookieSecure: true, + cookieHttpOnly: true, + cookieSameSite: SameSite::Strict, + ); + $header = CookieParser::formatSetCookie($original); + $parsed = CookieParser::parseSetCookie($header); + + self::assertSame($original->name(), $parsed->name()); + self::assertSame($original->value(), $parsed->value()); + self::assertSame( + $original->expires()?->format(DateTimeInterface::ATOM), + $parsed->expires()?->format(DateTimeInterface::ATOM), + ); + self::assertSame($original->maxAge(), $parsed->maxAge()); + self::assertSame($original->domain(), $parsed->domain()); + self::assertSame($original->path(), $parsed->path()); + self::assertSame($original->secure(), $parsed->secure()); + self::assertSame($original->httpOnly(), $parsed->httpOnly()); + self::assertSame($original->sameSite(), $parsed->sameSite()); + } + + #[Test] + public function roundTripMinimalCookie(): void + { + $original = new StrictCookie(cookieName: 'x', cookieValue: 'y'); + $parsed = CookieParser::parseSetCookie(CookieParser::formatSetCookie($original)); + self::assertSame('x', $parsed->name()); + self::assertSame('y', $parsed->value()); + self::assertSame('/', $parsed->path()); + self::assertSame(SameSite::Lax, $parsed->sameSite()); + } +} diff --git a/test/Unit/SameSiteTest.php b/test/Unit/SameSiteTest.php new file mode 100644 index 0000000..313b2f4 --- /dev/null +++ b/test/Unit/SameSiteTest.php @@ -0,0 +1,71 @@ +value); + } + + #[Test] + public function laxMatchesWireToken(): void + { + self::assertSame('Lax', SameSite::Lax->value); + } + + #[Test] + public function noneMatchesWireToken(): void + { + self::assertSame('None', SameSite::None->value); + } + + #[Test] + public function fromAcceptsCanonicalCases(): void + { + self::assertSame(SameSite::Strict, SameSite::from('Strict')); + self::assertSame(SameSite::Lax, SameSite::from('Lax')); + self::assertSame(SameSite::None, SameSite::from('None')); + } + + #[Test] + public function fromIsCaseSensitive(): void + { + // Wire format uses capitalized tokens; lower- or upper-case variants + // are not part of the contract. tryFrom returns null for those. + self::assertNull(SameSite::tryFrom('strict')); + self::assertNull(SameSite::tryFrom('LAX')); + self::assertNull(SameSite::tryFrom('none')); + } + + #[Test] + public function casesReturnsAllThree(): void + { + $cases = SameSite::cases(); + self::assertCount(3, $cases); + self::assertContains(SameSite::Strict, $cases); + self::assertContains(SameSite::Lax, $cases); + self::assertContains(SameSite::None, $cases); + } +} diff --git a/test/Unit/StrictCookieTest.php b/test/Unit/StrictCookieTest.php new file mode 100644 index 0000000..ca2f2dc --- /dev/null +++ b/test/Unit/StrictCookieTest.php @@ -0,0 +1,379 @@ +name()); + self::assertSame('', $cookie->value()); + self::assertNull($cookie->expires()); + self::assertNull($cookie->maxAge()); + self::assertNull($cookie->domain()); + self::assertSame('/', $cookie->path()); + self::assertFalse($cookie->secure()); + self::assertFalse($cookie->httpOnly()); + self::assertSame(SameSite::Lax, $cookie->sameSite()); + } + + #[Test] + public function allFieldsRoundTrip(): void + { + $expires = new DateTimeImmutable('2026-12-31T23:59:59+00:00'); + $cookie = new StrictCookie( + cookieName: 'Horde', + cookieValue: 'abc123', + cookieExpires: $expires, + cookieMaxAge: 3600, + cookieDomain: 'example.com', + cookiePath: '/horde', + cookieSecure: true, + cookieHttpOnly: true, + cookieSameSite: SameSite::Strict, + ); + + self::assertSame('Horde', $cookie->name()); + self::assertSame('abc123', $cookie->value()); + self::assertSame($expires, $cookie->expires()); + self::assertSame(3600, $cookie->maxAge()); + self::assertSame('example.com', $cookie->domain()); + self::assertSame('/horde', $cookie->path()); + self::assertTrue($cookie->secure()); + self::assertTrue($cookie->httpOnly()); + self::assertSame(SameSite::Strict, $cookie->sameSite()); + } + + // --------------------------------------------------------------- + // Name validation + // --------------------------------------------------------------- + + #[Test] + public function emptyNameRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('must not be empty'); + new StrictCookie(cookieName: ''); + } + + #[Test] + #[TestWith(['has space'])] + #[TestWith(['has;semi'])] + #[TestWith(['has,comma'])] + #[TestWith(['has=equal'])] + #[TestWith(['has"quote'])] + #[TestWith(['has(paren'])] + #[TestWith(["has\ttab"])] + #[TestWith(["has\nnewline"])] + public function nonTokenNameRejected(string $bad): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('RFC 7230 token characters'); + new StrictCookie(cookieName: $bad); + } + + #[Test] + #[TestWith(['session'])] + #[TestWith(['SESSION'])] + #[TestWith(['session_id'])] + #[TestWith(['session-id'])] + #[TestWith(['session.id'])] + #[TestWith(['__Host-session'])] + #[TestWith(['_csrf'])] + public function validNamesAccepted(string $good): void + { + $cookie = new StrictCookie(cookieName: $good); + self::assertSame($good, $cookie->name()); + } + + // --------------------------------------------------------------- + // Value validation (strict-only encoding policy) + // --------------------------------------------------------------- + + #[Test] + #[TestWith(['hello; world'])] // semicolon + #[TestWith(['hello, world'])] // comma + #[TestWith(['hello world'])] // space + #[TestWith(['hello"world'])] // quote + #[TestWith(['hello\\world'])] // backslash + public function nonOctetValueRejected(string $bad): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('cookie-octet range'); + new StrictCookie(cookieName: 'x', cookieValue: $bad); + } + + #[Test] + public function preEncodedValueAccepted(): void + { + // Caller's responsibility to encode; we accept the result. + $cookie = new StrictCookie( + cookieName: 'x', + cookieValue: rawurlencode('hello; world'), + ); + self::assertSame('hello%3B%20world', $cookie->value()); + } + + #[Test] + #[TestWith([''])] // empty allowed + #[TestWith(['abc123'])] // alphanumeric + #[TestWith(['eyJhbGciOiJIUzI1NiJ9.payload'])] // JWT-shaped + #[TestWith(['a_b-c.d~e/f'])] // safe punctuation + public function validValuesAccepted(string $good): void + { + $cookie = new StrictCookie(cookieName: 'x', cookieValue: $good); + self::assertSame($good, $cookie->value()); + } + + // --------------------------------------------------------------- + // Other validation rules + // --------------------------------------------------------------- + + #[Test] + public function negativeMaxAgeRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('non-negative'); + new StrictCookie(cookieName: 'x', cookieMaxAge: -1); + } + + #[Test] + public function zeroMaxAgeAccepted(): void + { + // Max-Age=0 is the spec-defined deletion signal; not an error. + $cookie = new StrictCookie(cookieName: 'x', cookieMaxAge: 0); + self::assertSame(0, $cookie->maxAge()); + } + + #[Test] + public function pathMustStartWithSlash(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Path must start with "/"'); + new StrictCookie(cookieName: 'x', cookiePath: 'horde'); + } + + #[Test] + public function emptyPathRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Path must start with "/"'); + new StrictCookie(cookieName: 'x', cookiePath: ''); + } + + #[Test] + public function sameSiteNoneRequiresSecure(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('SameSite=None'); + new StrictCookie( + cookieName: 'x', + cookieSameSite: SameSite::None, + cookieSecure: false, + ); + } + + #[Test] + public function sameSiteNoneWithSecureAccepted(): void + { + $cookie = new StrictCookie( + cookieName: 'x', + cookieSecure: true, + cookieSameSite: SameSite::None, + ); + self::assertSame(SameSite::None, $cookie->sameSite()); + self::assertTrue($cookie->secure()); + } + + // --------------------------------------------------------------- + // Immutability + // --------------------------------------------------------------- + + #[Test] + public function withValueReturnsNewInstance(): void + { + $original = new StrictCookie(cookieName: 'x', cookieValue: 'a'); + $changed = $original->withValue('b'); + + self::assertNotSame($original, $changed); + self::assertSame('a', $original->value()); + self::assertSame('b', $changed->value()); + } + + #[Test] + public function withSameValueReturnsSameInstance(): void + { + // Optimisation: avoid pointless allocation when nothing changes. + $original = new StrictCookie(cookieName: 'x', cookieValue: 'a'); + self::assertSame($original, $original->withValue('a')); + } + + #[Test] + public function allWithMethodsPreserveOtherFields(): void + { + $expires = new DateTimeImmutable('2026-12-31T23:59:59+00:00'); + $original = new StrictCookie( + cookieName: 'Horde', + cookieValue: 'abc', + cookieExpires: $expires, + cookieMaxAge: 3600, + cookieDomain: 'example.com', + cookiePath: '/horde', + cookieSecure: true, + cookieHttpOnly: true, + cookieSameSite: SameSite::Strict, + ); + + $assertions = [ + 'value' => fn() => $original->withValue('xyz'), + 'expires' => fn() => $original->withExpires(new DateTimeImmutable('2027-01-01T00:00:00+00:00')), + 'maxAge' => fn() => $original->withMaxAge(7200), + 'domain' => fn() => $original->withDomain('other.example.com'), + 'path' => fn() => $original->withPath('/other'), + 'secure' => fn() => $original->withSecure(false), + 'httpOnly' => fn() => $original->withHttpOnly(false), + 'sameSite' => fn() => $original->withSameSite(SameSite::Lax), + ]; + + foreach ($assertions as $changedField => $build) { + $changed = $build(); + self::assertNotSame($original, $changed, "with{$changedField} returned same instance"); + self::assertSame('Horde', $changed->name(), "with{$changedField} dropped name"); + + // Spot-check that one unrelated field per call survived. + if ($changedField !== 'value') { + self::assertSame('abc', $changed->value(), "with{$changedField} clobbered value"); + } + if ($changedField !== 'path') { + self::assertSame('/horde', $changed->path(), "with{$changedField} clobbered path"); + } + if ($changedField !== 'sameSite') { + self::assertSame(SameSite::Strict, $changed->sameSite(), "with{$changedField} clobbered sameSite"); + } + } + } + + #[Test] + public function withSecureCanRelaxFromNoneToLax(): void + { + // SameSite=None requires Secure. Removing Secure first requires + // also relaxing SameSite. Order matters. + $cookie = new StrictCookie( + cookieName: 'x', + cookieSecure: true, + cookieSameSite: SameSite::None, + ); + $relaxed = $cookie->withSameSite(SameSite::Lax)->withSecure(false); + self::assertFalse($relaxed->secure()); + self::assertSame(SameSite::Lax, $relaxed->sameSite()); + } + + #[Test] + public function withSecureFalseOnSameSiteNoneStillThrows(): void + { + // Caller forgot to relax SameSite first: validation on the new + // instance fires. + $cookie = new StrictCookie( + cookieName: 'x', + cookieSecure: true, + cookieSameSite: SameSite::None, + ); + $this->expectException(InvalidArgumentException::class); + $cookie->withSecure(false); + } + + // --------------------------------------------------------------- + // deletion() + // --------------------------------------------------------------- + + #[Test] + public function deletionPreservesScope(): void + { + $original = new StrictCookie( + cookieName: 'session', + cookieValue: 'abc123', + cookieDomain: 'example.com', + cookiePath: '/horde', + cookieSecure: true, + cookieHttpOnly: true, + ); + $deletion = $original->deletion(); + + self::assertSame('session', $deletion->name()); + self::assertSame('', $deletion->value()); + self::assertSame(0, $deletion->maxAge()); + self::assertNull($deletion->expires()); + self::assertSame('example.com', $deletion->domain()); + self::assertSame('/horde', $deletion->path()); + self::assertTrue($deletion->secure()); + self::assertTrue($deletion->httpOnly()); + } + + #[Test] + public function deletionReturnsNewInstance(): void + { + $original = new StrictCookie(cookieName: 'x', cookieValue: 'a'); + self::assertNotSame($original, $original->deletion()); + } + + // --------------------------------------------------------------- + // Formatter delegation (substance covered in CookieParserTest) + // --------------------------------------------------------------- + + #[Test] + public function toSetCookieHeaderDelegates(): void + { + // Just check it returns a non-empty string with the name; the full + // wire format is exercised in CookieParserTest. + $header = (new StrictCookie(cookieName: 'session', cookieValue: 'abc')) + ->toSetCookieHeader(); + self::assertStringContainsString('session=abc', $header); + self::assertStringContainsString('Path=/', $header); + self::assertStringContainsString('SameSite=Lax', $header); + } + + #[Test] + public function toCookieHeaderDelegates(): void + { + $pair = (new StrictCookie(cookieName: 'session', cookieValue: 'abc')) + ->toCookieHeader(); + self::assertSame('session=abc', $pair); + } +}