* SPDX-License-Identifier: Apache-2.0 * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * See the LICENSE file in the project root for the full license text. */ declare(strict_types=1); namespace App\Tests\Http; use App\Http\Response; use InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; /** * R01-N20: pin the redirect contract. * * `Response::redirect` is path-only. `Response::external` is the explicit * cross-origin helper. Both reject CR/LF (header injection) and protocol- * relative shapes (`//host/path`) that look like paths but leave the * origin. */ final class ResponseTest extends TestCase { /** @return list */ public static function pathOnlyAccepted(): array { return [ ['/', 'root'], ['/foo', 'simple path'], ['/users?error=db', 'path with query string'], ['/sprints/1#tab=tasks', 'path with fragment'], ['/users?flash=demoted', 'flash query'], ['/sprints/import/abc-123', 'token-style segment'], ]; } #[DataProvider('pathOnlyAccepted')] public function testRedirectAcceptsPathOnlyLocations(string $location, string $label): void { $resp = Response::redirect($location); self::assertSame(302, $resp->status, $label); self::assertSame($location, $resp->headers['Location'], $label); } public function testRedirectAcceptsCustomStatus(): void { $resp = Response::redirect('/foo', 303); self::assertSame(303, $resp->status); } /** @return list */ public static function nonPathLocations(): array { return [ ['', 'empty string'], ['foo', 'relative path'], ['foo/bar', 'multi-segment relative'], ['?next=/x', 'query-only (relative)'], ['#anchor', 'fragment-only'], ['//evil.example.com/x', 'protocol-relative URL → off-origin redirect'], ['///evil.example.com/x', 'triple-slash variant'], ['https://evil.example.com/x', 'absolute https URL'], ['http://evil.example.com/', 'absolute http URL'], ['javascript:alert(1)', 'javascript: scheme'], ['data:text/html,