1
0

ResponseTest.php 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  1. <?php
  2. /*
  3. * Copyright 2026 Alessandro Chiapparini <sprint_planer_web@chiapparini.org>
  4. * SPDX-License-Identifier: Apache-2.0
  5. *
  6. * Licensed under the Apache License, Version 2.0 (the "License");
  7. * you may not use this file except in compliance with the License.
  8. * See the LICENSE file in the project root for the full license text.
  9. */
  10. declare(strict_types=1);
  11. namespace App\Tests\Http;
  12. use App\Http\Response;
  13. use InvalidArgumentException;
  14. use PHPUnit\Framework\Attributes\DataProvider;
  15. use PHPUnit\Framework\TestCase;
  16. /**
  17. * R01-N20: pin the redirect contract.
  18. *
  19. * `Response::redirect` is path-only. `Response::external` is the explicit
  20. * cross-origin helper. Both reject CR/LF (header injection) and protocol-
  21. * relative shapes (`//host/path`) that look like paths but leave the
  22. * origin.
  23. */
  24. final class ResponseTest extends TestCase
  25. {
  26. /** @return list<array{0:string,1:string}> */
  27. public static function pathOnlyAccepted(): array
  28. {
  29. return [
  30. ['/', 'root'],
  31. ['/foo', 'simple path'],
  32. ['/users?error=db', 'path with query string'],
  33. ['/sprints/1#tab=tasks', 'path with fragment'],
  34. ['/users?flash=demoted', 'flash query'],
  35. ['/sprints/import/abc-123', 'token-style segment'],
  36. ];
  37. }
  38. #[DataProvider('pathOnlyAccepted')]
  39. public function testRedirectAcceptsPathOnlyLocations(string $location, string $label): void
  40. {
  41. $resp = Response::redirect($location);
  42. self::assertSame(302, $resp->status, $label);
  43. self::assertSame($location, $resp->headers['Location'], $label);
  44. }
  45. public function testRedirectAcceptsCustomStatus(): void
  46. {
  47. $resp = Response::redirect('/foo', 303);
  48. self::assertSame(303, $resp->status);
  49. }
  50. /** @return list<array{0:string,1:string}> */
  51. public static function nonPathLocations(): array
  52. {
  53. return [
  54. ['', 'empty string'],
  55. ['foo', 'relative path'],
  56. ['foo/bar', 'multi-segment relative'],
  57. ['?next=/x', 'query-only (relative)'],
  58. ['#anchor', 'fragment-only'],
  59. ['//evil.example.com/x', 'protocol-relative URL → off-origin redirect'],
  60. ['///evil.example.com/x', 'triple-slash variant'],
  61. ['https://evil.example.com/x', 'absolute https URL'],
  62. ['http://evil.example.com/', 'absolute http URL'],
  63. ['javascript:alert(1)', 'javascript: scheme'],
  64. ['data:text/html,<script>', 'data: scheme'],
  65. ["/foo\r\nX-Injected: yes", 'CR/LF — header injection'],
  66. ["/foo\nLocation: https://evil/", 'LF — header injection'],
  67. ["/foo\0bar", 'null byte'],
  68. ];
  69. }
  70. #[DataProvider('nonPathLocations')]
  71. public function testRedirectRejectsNonPathLocations(string $location, string $label): void
  72. {
  73. $this->expectException(InvalidArgumentException::class);
  74. $this->expectExceptionMessage('path-only');
  75. Response::redirect($location);
  76. }
  77. /** @return list<array{0:string,1:string}> */
  78. public static function externalAccepted(): array
  79. {
  80. return [
  81. ['https://app.example.com/', 'https + path'],
  82. ['https://app.example.com/sprints/1?tab=foo', 'https + query'],
  83. ['http://localhost:8080/healthz', 'http on localhost with port'],
  84. ['HTTPS://APP.EXAMPLE.COM/x', 'uppercase scheme is normalised at compare'],
  85. ];
  86. }
  87. #[DataProvider('externalAccepted')]
  88. public function testExternalAcceptsHttpAndHttpsUrls(string $url, string $label): void
  89. {
  90. $resp = Response::external($url, 308);
  91. self::assertSame(308, $resp->status, $label);
  92. self::assertSame($url, $resp->headers['Location'], $label);
  93. }
  94. /** @return list<array{0:string,1:string}> */
  95. public static function externalRejected(): array
  96. {
  97. return [
  98. ['', 'empty'],
  99. ['/foo', 'path-only — must use redirect()'],
  100. ['//evil.example.com/x', 'protocol-relative'],
  101. ['javascript:alert(1)', 'javascript: scheme'],
  102. ['file:///etc/passwd', 'file: scheme'],
  103. ['ftp://files.example.com/x', 'ftp: scheme'],
  104. ['data:text/html,<x>', 'data: scheme'],
  105. ['https://', 'no host'],
  106. ['just text', 'unparseable'],
  107. ["https://evil.example.com/\r\n", 'CR/LF in URL'],
  108. ];
  109. }
  110. #[DataProvider('externalRejected')]
  111. public function testExternalRejectsNonHttpSchemesAndMalformed(string $url, string $label): void
  112. {
  113. $this->expectException(InvalidArgumentException::class);
  114. Response::external($url);
  115. }
  116. public function testIsPathOnlyHelperMatchesRedirectContract(): void
  117. {
  118. // Drift fence: if these helpers ever diverge from what redirect()
  119. // / external() accept, the test above will fail too — but pin the
  120. // pure helper independently so refactors are caught instantly.
  121. self::assertTrue(Response::isPathOnly('/'));
  122. self::assertTrue(Response::isPathOnly('/users'));
  123. self::assertFalse(Response::isPathOnly('//evil'));
  124. self::assertFalse(Response::isPathOnly(''));
  125. self::assertFalse(Response::isPathOnly('users'));
  126. self::assertFalse(Response::isPathOnly("/x\nfoo"));
  127. self::assertTrue(Response::isExternalUrl('https://x.example/'));
  128. self::assertTrue(Response::isExternalUrl('http://x.example/'));
  129. self::assertFalse(Response::isExternalUrl('/foo'));
  130. self::assertFalse(Response::isExternalUrl('javascript:x'));
  131. self::assertFalse(Response::isExternalUrl('//x.example/'));
  132. }
  133. }