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); + } +}