<?php
// RouterTest.php
// Created: 2022-01-20
// Updated: 2025-03-23

declare(strict_types=1);

use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\CoversClass;
use Index\Http\{HttpHeaders,HttpResponseBuilder,HttpRequest,HttpUri};
use Index\Http\Content\{FormContent,MultipartFormContent,UrlEncodedFormContent};
use Index\Http\Routing\{HandlerContext,RouteHandler,RouteHandlerCommon,Router};
use Index\Http\Routing\AccessControl\{AccessControl};
use Index\Http\Routing\Filters\{FilterInfo,PrefixFilter};
use Index\Http\Routing\Processors\{After,Before,Postprocessor,Preprocessor,ProcessorInfo};
use Index\Http\Routing\Routes\{ExactRoute,PatternRoute,RouteInfo};
use Index\Http\Streams\Stream;

/**
 * This test isn't super representative of the current functionality
 *  it mostly just does the same tests that were done against the previous implementation
 */
#[CoversClass(Router::class)]
#[CoversClass(RouteInfo::class)]
#[CoversClass(PrefixFilter::class)]
#[CoversClass(ExactRoute::class)]
#[CoversClass(RouteHandler::class)]
#[CoversClass(RouteHandlerCommon::class)]
#[CoversClass(PatternRoute::class)]
#[CoversClass(FilterInfo::class)]
#[CoversClass(AccessControl::class)]
final class RouterTest extends TestCase {
    public function testRouter(): void {
        $router1 = new Router;

        $router1->route(RouteInfo::exact('GET', '/', fn() => 'get'));
        $router1->route(RouteInfo::exact('POST', '/', fn() => 'post'));
        $router1->route(RouteInfo::exact('DELETE', '/', fn() => 'delete'));
        $router1->route(RouteInfo::exact('PATCH', '/', fn() => 'patch'));
        $router1->route(RouteInfo::exact('PUT', '/', fn() => 'put'));
        $router1->route(RouteInfo::exact('CUSTOM', '/', fn() => 'wacky'));

        $this->assertSame('get', (string)$router1->handle(HttpRequest::createRequestWithoutBody('GET', '/'))->getBody());
        $this->assertSame('wacky', (string)$router1->handle(HttpRequest::createRequestWithoutBody('CUSTOM', '/'))->getBody());

        $router1->filter(FilterInfo::prefix('/', function() { /* this one intentionally does nothing */ }));

        // registration order should matter
        $router1->filter(FilterInfo::prefix('/deep', fn() => 'deep'));

        $router1->filter(FilterInfo::pattern('#^/user/([A-Za-z0-9]+)/below#u', fn(string $user) => 'warioware below ' . $user));

        $router1->route(RouteInfo::exact('GET', '/user/static', fn() => 'the static one'));
        $router1->route(RouteInfo::exact('GET', '/user/static/below', fn() => 'below the static one'));
        $router1->route(RouteInfo::pattern('GET', '#^/user/([A-Za-z0-9]+)$#uD', fn(string $user) => $user));
        $router1->route(RouteInfo::pattern('GET', '#^/user/([A-Za-z0-9]+)/below$#uD', fn(string $user) => 'below ' . $user));

        $this->assertSame('warioware below static', (string)$router1->handle(HttpRequest::createRequestWithoutBody('GET', '/user/static/below'))->getBody());

        $router2 = new Router;
        $router2->filter(FilterInfo::prefix('/', fn() => 'meow'));
        $router2->route(RouteInfo::exact('GET', '/rules', fn() => 'rules page'));
        $router2->route(RouteInfo::exact('GET', '/contact', fn() => 'contact page'));
        $router2->route(RouteInfo::exact('GET', '/25252', fn() => 'numeric test'));

        $this->assertSame('meow', (string)$router2->handle(HttpRequest::createRequestWithoutBody('GET', '/rules'))->getBody());
    }

    public function testAttribute(): void {
        $router = new Router;
        $handler = new class implements RouteHandler {
            use RouteHandlerCommon;

            #[ExactRoute('GET', '/')]
            public function getIndex(): string {
                return 'index';
            }

            #[ExactRoute('POST', '/avatar')]
            public function postAvatar(): string {
                return 'avatar';
            }

            #[ExactRoute('PUT', '/static')]
            public static function putStatic(): string {
                return 'static';
            }

            #[ExactRoute('GET', '/meow')]
            #[ExactRoute('POST', '/meow')]
            public function multiple(): string {
                return 'meow';
            }

            #[PrefixFilter('/filter')]
            public function useFilter(): string {
                return 'this intercepts';
            }

            #[ExactRoute('GET', '/filter')]
            public function getFilter(): string {
                return 'this is intercepted';
            }

            #[PatternRoute('GET', '/profile/([A-Za-z0-9]+)')]
            public function getPattern(string $beans): string {
                return sprintf('profile of %s', $beans);
            }

            #[PatternRoute('GET', '#^/profile-but-raw/([A-Za-z0-9]+)$#uD', raw: true)]
            public function getPatternRaw(string $beans): string {
                return sprintf('still the profile of %s', $beans);
            }

            #[ExactRoute('GET', '/announce')]
            #[ExactRoute('GET', '/announce.php')]
            #[PatternRoute('GET', '/announce/([A-Za-z0-9]+)')]
            #[PatternRoute('GET', '/announce.php/([A-Za-z0-9]+)')]
            public function getMultipleTestWithDefault(string $key = 'empty'): string {
                return $key;
            }

            #[PatternRoute('GET', '/uploads/([A-Za-z0-9]+|[A-Za-z0-9\-_]{32})(?:-([a-z0-9]+))?(?:\.([A-Za-z0-9\-_]+))?')]
            public function getEepromEdgeCase(
                string $uploadId,
                string $variant = '',
                string $extension = ''
            ): string {
                return sprintf('%s//%s//%s', $uploadId, $variant, $extension);
            }

            #[ExactRoute('GET', '/messages/stats')]
            public function getStats(): string {
                return 'hit exact';
            }

            #[PatternRoute('GET', '/messages/([A-Za-z0-9]+)')]
            public function getView(): string {
                return 'hit pattern';
            }

            #[ExactRoute('GET', '/assets/avatar')]
            #[PatternRoute('GET', '/assets/avatar/([0-9]+)(?:\.[a-z]+)?')]
            public function getTrailingSlash(): string {
                return 'hit';
            }

            public function hasNoAttr(): string {
                return 'not a route';
            }
        };

        $router->register($handler);

        $this->assertSame('index', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/'))->getBody());
        $this->assertSame('avatar', (string)$router->handle(HttpRequest::createRequestWithoutBody('POST', '/avatar'))->getBody());
        $this->assertSame('static', (string)$router->handle(HttpRequest::createRequestWithoutBody('PUT', '/static'))->getBody());
        $this->assertSame('meow', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/meow'))->getBody());
        $this->assertSame('meow', (string)$router->handle(HttpRequest::createRequestWithoutBody('POST', '/meow'))->getBody());
        $this->assertSame('profile of Cool134', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/profile/Cool134'))->getBody());
        $this->assertSame('still the profile of Cool134', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/profile-but-raw/Cool134'))->getBody());
        $this->assertSame('empty', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/announce'))->getBody());
        $this->assertSame('empty', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/announce.php'))->getBody());
        $this->assertSame('Z643QANLgGNkF4D4h4qvFpyeXjx4TcDE', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/announce/Z643QANLgGNkF4D4h4qvFpyeXjx4TcDE'))->getBody());
        $this->assertSame('1aKq8VaGyHohNUUR7RzU1W57Z3hQ6m0YMazAkr2IoiSPsvQJ6QoQutywwiOBlNka', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/announce.php/1aKq8VaGyHohNUUR7RzU1W57Z3hQ6m0YMazAkr2IoiSPsvQJ6QoQutywwiOBlNka'))->getBody());
        $this->assertSame('1RJNSRYmxrvXUr////', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/uploads/1RJNSRYmxrvXUr'))->getBody());
        $this->assertSame('1RJNSRYmxrvXUr//thumb//', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/uploads/1RJNSRYmxrvXUr-thumb'))->getBody());
        $this->assertSame('1RJNSRYmxrvXUr//thumb//jpg', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/uploads/1RJNSRYmxrvXUr-thumb.jpg'))->getBody());
        $this->assertSame('1RJNSRYmxrvXUr////jpg', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/uploads/1RJNSRYmxrvXUr.jpg'))->getBody());
        $this->assertSame('hit exact', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/messages/stats'))->getBody());
        $this->assertSame('hit pattern', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/messages/soaps'))->getBody());
        $this->assertSame('hit', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/assets/avatar/'))->getBody());
        $this->assertSame('hit', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/assets/avatar/123/'))->getBody());
    }

    public function testEEPROMSituation(): void {
        $router = new Router;
        $router->route(RouteInfo::pattern('GET', '#^/uploads/([A-Za-z0-9\-_]+)(?:\.(t|json))?$#uD', fn(string $id) => "Get {$id}"));
        $router->route(RouteInfo::pattern('DELETE', '#^/uploads/([A-Za-z0-9\-_]+)$#uD', fn(string $id) => "Delete {$id}"));

        // make sure both GET and DELETE are able to execute with a different pattern
        $this->assertSame('Get AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/uploads/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'))->getBody());
        $this->assertSame('Delete BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', (string)$router->handle(HttpRequest::createRequestWithoutBody('DELETE', '/uploads/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'))->getBody());
    }

    public function testFilterInterceptionOnRoot(): void {
        $router = new Router;
        $router->filter(FilterInfo::prefix('/', fn() => 'expected'));
        $router->route(RouteInfo::exact('GET', '/', fn() => 'unexpected'));
        $router->route(RouteInfo::exact('GET', '/test', fn() => 'also unexpected'));

        $this->assertSame('expected', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/'))->getBody());
        $this->assertSame('expected', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/test'))->getBody());
        $this->assertSame('expected', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/error'))->getBody());
    }

    public function testDefaultOptionsImplementation(): void {
        $router = new Router;
        $router->route(RouteInfo::exact('GET', '/test', fn() => 'get'));
        $router->route(RouteInfo::exact('POST', '/test', fn() => 'post'));
        $router->route(RouteInfo::exact('PATCH', '/test', fn() => 'patch'));

        $response = $router->handle(HttpRequest::createRequestWithoutBody('OPTIONS', '/test'));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertSame('OK', $response->getReasonPhrase());
        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
        $this->assertSame('GET, HEAD, OPTIONS, PATCH, POST', $response->getHeaderLine('Allow'));
    }

    public function testDefaultProcessorsNoRegister(): void {
        $this->expectException(RuntimeException::class);
        $this->expectExceptionMessage(sprintf('preprocessor "%s" was not found', 'input:urlencoded:required'));

        $router = new Router(registerDefaultProcessors: false);
        $router->route(RouteInfo::exact(
            'GET', '/soap',
            #[Before('input:urlencoded:required')]
            function() {
                return 'test';
            },
        ));
        $router->handle(HttpRequest::createRequestWithoutBody('GET', '/soap'));
    }

    public function testDefaultProcessors(): void {
        $router = new Router;
        $router->register(new class implements RouteHandler {
            use RouteHandlerCommon;

            #[Before('input:urlencoded', required: false)]
            #[Before('input:multipart', required: false)]
            #[ExactRoute('POST', '/optional-form')]
            public function formOptional(?FormContent $content = null): string {
                if($content instanceof MultipartFormContent)
                    return 'multipart:' . (string)$content->getParam('test');
                if($content instanceof UrlEncodedFormContent)
                    return 'urlencoded:' . (string)$content->getParam('test');
                return 'none';
            }

            #[Before('input:multipart')]
            #[ExactRoute('POST', '/required-multipart')]
            public function multipartRequired(MultipartFormContent $content): string {
                return (string)$content->getParam('test');
            }

            #[Before('input:urlencoded')]
            #[ExactRoute('POST', '/required-urlencoded')]
            public function urlencodedRequired(UrlEncodedFormContent $content): string {
                return (string)$content->getParam('test');
            }

            #[After('output:stream')]
            #[ExactRoute('GET', '/output/stream')]
            public function testOutputStream(): string {
                return '<!doctype html><h1>ensuring autodetect gets skipped</h1>';
            }

            #[After('output:plain', charset: 'utf-8')]
            #[ExactRoute('GET', '/output/plain')]
            public function testOutputPlain(): string {
                return '<!doctype html><h1>ensuring autodetect gets skipped</h1>';
            }

            #[After('output:html', charset: 'us-ascii')]
            #[ExactRoute('GET', '/output/html')]
            public function testOutputHtml(): string {
                return '<?xml idk how xml opens the prefix is enough><beans></beans>';
            }

            #[After('output:xml')]
            #[ExactRoute('GET', '/output/xml')]
            public function testOutputXml(): string {
                return 'soup';
            }

            #[After('output:css')]
            #[ExactRoute('GET', '/output/css')]
            public function testOutputCss(): string {
                return '<!doctype html><h1>ensuring autodetect gets skipped</h1>';
            }

            #[After('output:js')]
            #[ExactRoute('GET', '/output/js')]
            public function testOutputJs(): string {
                return '<!doctype html><h1>ensuring autodetect gets skipped</h1>';
            }

            /** @return array{wow: string} */
            #[After('output:json', flags: 0)]
            #[ExactRoute('GET', '/output/json')]
            public function testOutputJson(): array {
                return ['wow' => 'objects?? / epic!!'];
            }

            /** @return array{benben: int} */
            #[After('output:bencode')]
            #[ExactRoute('GET', '/output/bencode')]
            public function testOutputBencode(): array {
                return ['benben' => 12345];
            }

            #[After('output:bencode')]
            #[ExactRoute('GET', '/output/bencode/object')]
            public function testOutputBencodeObject(): object {
                return new class implements \Index\Bencode\BencodeSerializable {
                    use \Index\Bencode\BencodeSerializableCommon;

                    public function __construct(
                        #[\Index\Bencode\BencodeProperty('failure reason')]
                        public private(set) string $reason = 'Invalid info hash.'
                    ) {}
                };
            }
        });

        $response = $router->handle(HttpRequest::createRequestWithoutBody('GET', '/output/stream'));
        $this->assertSame('application/octet-stream', $response->getHeaderLine('Content-Type'));
        $this->assertSame('<!doctype html><h1>ensuring autodetect gets skipped</h1>', (string)$response->getBody());

        $response = $router->handle(HttpRequest::createRequestWithoutBody('GET', '/output/plain'));
        $this->assertSame('text/plain;charset=utf-8', $response->getHeaderLine('Content-Type'));
        $this->assertSame('<!doctype html><h1>ensuring autodetect gets skipped</h1>', (string)$response->getBody());

        $response = $router->handle(HttpRequest::createRequestWithoutBody('GET', '/output/html'));
        $this->assertSame('text/html;charset=us-ascii', $response->getHeaderLine('Content-Type'));
        $this->assertSame('<?xml idk how xml opens the prefix is enough><beans></beans>', (string)$response->getBody());

        $response = $router->handle(HttpRequest::createRequestWithoutBody('GET', '/output/xml'));
        $this->assertSame('application/xml', $response->getHeaderLine('Content-Type'));
        $this->assertSame('soup', (string)$response->getBody());

        $response = $router->handle(HttpRequest::createRequestWithoutBody('GET', '/output/css'));
        $this->assertSame('text/css', $response->getHeaderLine('Content-Type'));
        $this->assertSame('<!doctype html><h1>ensuring autodetect gets skipped</h1>', (string)$response->getBody());

        $response = $router->handle(HttpRequest::createRequestWithoutBody('GET', '/output/js'));
        $this->assertSame('application/javascript', $response->getHeaderLine('Content-Type'));
        $this->assertSame('<!doctype html><h1>ensuring autodetect gets skipped</h1>', (string)$response->getBody());

        $response = $router->handle(HttpRequest::createRequestWithoutBody('GET', '/output/json'));
        $this->assertSame('application/json', $response->getHeaderLine('Content-Type'));
        $this->assertSame('{"wow":"objects?? \/ epic!!"}', (string)$response->getBody());

        $response = $router->handle(HttpRequest::createRequestWithoutBody('GET', '/output/bencode'));
        $this->assertSame('application/x-bittorrent', $response->getHeaderLine('Content-Type'));
        $this->assertSame('d6:benbeni12345ee', (string)$response->getBody());

        $response = $router->handle(HttpRequest::createRequestWithoutBody('GET', '/output/bencode/object'));
        $this->assertSame('application/x-bittorrent', $response->getHeaderLine('Content-Type'));
        $this->assertSame('d14:failure reason18:Invalid info hash.e', (string)$response->getBody());

        $response = $router->handle(HttpRequest::createRequestWithoutBody('POST', '/optional-form'));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertSame('none', (string)$response->getBody());

        $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/optional-form', [], Stream::createStream('test=mewow')));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertSame('none', (string)$response->getBody());

        $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/optional-form', ['Content-Type' => ['application/x-www-form-urlencoded']], Stream::createStream('test=mewow')));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertSame('urlencoded:mewow', (string)$response->getBody());

        // an empty string is valid too
        $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/optional-form', ['Content-Type' => ['application/x-www-form-urlencoded']], Stream::createStream('')));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertSame('urlencoded:', (string)$response->getBody());

        $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/optional-form', ['Content-Type' => ['multipart/form-data; boundary="--soap12345"']], Stream::createStream(implode("\r\n", [
            '----soap12345',
            'Content-Disposition: form-data; name="test"',
            '',
            'wowof',
            '----soap12345--',
            '',
        ]))));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertSame('multipart:wowof', (string)$response->getBody());

        $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/optional-form', ['Content-Type' => ['multipart/form-data; boundary="--soap12345"']], Stream::createStream(implode("\r\n", [
            '----soap12345--',
            '',
        ]))));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertSame('multipart:', (string)$response->getBody());

        $response = $router->handle(HttpRequest::createRequestWithoutBody('POST', '/required-urlencoded'));
        $this->assertSame(400, $response->getStatusCode());

        $response = $router->handle(HttpRequest::createRequestWithoutBody('POST', '/required-multipart'));
        $this->assertSame(400, $response->getStatusCode());

        $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/required-urlencoded', ['Content-Type' => ['application/x-www-form-urlencoded']], Stream::createStream('test=meow')));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertSame('meow', (string)$response->getBody());

        $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/required-multipart', ['Content-Type' => ['multipart/form-data; boundary="--soap56789"']], Stream::createStream(implode("\r\n", [
            '----soap56789',
            'Content-Disposition: form-data; name="test"',
            '',
            'woof',
            '----soap56789--',
            '',
        ]))));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertSame('woof', (string)$response->getBody());

        $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/required-urlencoded', ['Content-Type' => ['multipart/form-data; boundary="--soap56789"']], Stream::createStream('test=meow')));
        $this->assertSame(400, $response->getStatusCode());

        $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/required-multipart', ['Content-Type' => ['application/x-www-form-urlencoded']], Stream::createStream(implode("\r\n", [
            '----soap56789',
            'Content-Disposition: form-data; name="test"',
            '',
            'woof',
            '----soap56789--',
            '',
        ]))));
        $this->assertSame(400, $response->getStatusCode());

        $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/required-multipart', ['Content-Type' => ['multipart/form-data']], Stream::createStream(implode("\r\n", [
            '----aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
            'Content-Disposition: form-data; name="test"',
            '',
            'the',
            '----aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa--',
            '',
        ]))));
        $this->assertSame(400, $response->getStatusCode());
    }

    public function testProcessors(): void {
        $router = new Router;

        $router->register(new class implements RouteHandler {
            use RouteHandlerCommon;

            #[Preprocessor('base64-decode-body')]
            public function decodePre(HandlerContext $context, HttpRequest $request): void {
                $context->addArgument(base64_decode((string)$request->getBody()));
            }

            #[Postprocessor('crash')]
            public function crashPost(): void {
                throw new RuntimeException('this shouldnt run!');
            }

            #[Postprocessor('json-encode')]
            public function encodePost(HandlerContext $context, int $flags): void {
                $context->halt();
                $context->response->setTypeJson();
                $context->response->body = Stream::createStream(
                    json_encode($context->result, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR | $flags)
                );
            }

            /** @return array{data: string} */
            #[ExactRoute('POST', '/test')]
            #[Before('base64-decode-body')]
            #[After('json-encode', flags: JSON_PRETTY_PRINT)]
            #[After('crash')]
            public function route(string $decoded): array {
                return ['data' => $decoded];
            }
        });

        $router->preprocessor(ProcessorInfo::pre('intercept', function(HttpResponseBuilder $response) {
            $response->statusCode = 403;
            return 'it can work like a filter too';
        }));

        $router->route(RouteInfo::exact(
            'PUT', '/alternate',
            #[Before('base64-decode-body')]
            #[After('json-encode', flags: JSON_PRETTY_PRINT)]
            function(HttpRequest $request, string $decoded): array {
                return ['path' => $request->uri->path, 'data' => $decoded];
            }
        ));

        $router->route(RouteInfo::exact(
            'GET', '/filtered',
            #[Before('intercept')]
            #[Before('base64-decode-body')]
            function(): string {
                return 'should not get here';
            }
        ));

        $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/test', [], Stream::createStream(base64_encode('mewow'))));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertSame("{\n    \"data\": \"mewow\"\n}", (string)$response->getBody());

        $response = $router->handle(HttpRequest::createRequestWithBody('PUT', '/alternate', [], Stream::createStream(base64_encode('soap'))));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertSame("{\n    \"path\": \"/alternate\",\n    \"data\": \"soap\"\n}", (string)$response->getBody());

        $response = $router->handle(HttpRequest::createRequestWithoutBody('GET', '/filtered'));
        $this->assertSame(403, $response->getStatusCode());
        $this->assertSame('it can work like a filter too', (string)$response->getBody());
    }

    public function testAccessControl(): void {
        $router = new Router;

        $router->register(new class implements RouteHandler {
            use RouteHandlerCommon;


            #[ExactRoute('GET', '/nothing')]
            public function nothing(): void {}


            #[AccessControl]
            #[ExactRoute('GET', '/acld')]
            public function getAccessControlDifferent(): void {}

            #[ExactRoute('POST', '/acld')]
            public function postAccessControlDifferent(): void {}


            #[AccessControl]
            #[ExactRoute('GET', '/copdodnop')]
            public function getCredentialsOnPostDenyOnDeleteNothingOnPut(): void {}

            #[AccessControl(credentials: true)]
            #[ExactRoute('POST', '/copdodnop')]
            public function postCredentialsOnPostDenyOnDeleteNothingOnPut(): void {}

            #[AccessControl(allow: false)]
            #[ExactRoute('DELETE', '/copdodnop')]
            public function deleteCredentialsOnPostDenyOnDeleteNothingOnPut(): void {}

            #[ExactRoute('PUT', '/copdodnop')]
            public function putCredentialsOnPostDenyOnDeleteNothingOnPut(): void {}


            #[AccessControl(allowMethods: ['HEAD'])]
            #[ExactRoute('GET', '/methods/extra')]
            public function getMethodsExtra(): void {}


            #[AccessControl(allowHeaders: true)]
            #[ExactRoute('GET', '/headers/allow-all')]
            public function getHeadersAllowAll(): void {}

            #[AccessControl(allowHeaders: ['Authorization', 'X-Content-Index', 'Content-Type'])]
            #[ExactRoute('GET', '/headers/allow-specific')]
            public function getHeadersAllowSpecific(): void {}


            #[AccessControl]
            #[ExactRoute('GET', '/headers/expose-safe')]
            public function getHeadersExposeSafe(): void {}

            #[AccessControl(exposeHeaders: true)]
            #[ExactRoute('GET', '/headers/expose-all')]
            public function getHeadersExposeAll(): void {}

            #[AccessControl(exposeHeaders: ['X-EEPROM-Max-Size', 'Vary'])]
            #[ExactRoute('GET', '/headers/expose-specific')]
            public function getHeadersExposeSpecific(): void {}

            // including a little curveball! i'm sure this won't lead to confusion
            #[AccessControl(allow: true, credentials: true, allowMethods: ['POST', 'HEAD'], allowHeaders: ['Content-Length', 'X-Content-Index'], exposeHeaders: ['X-Satori-Time', 'X-Satori-Hash'])]
            #[ExactRoute('GET', '/all-together-now')]
            public function getAllTogetherNow(): void {}

            #[ExactRoute('POST', '/all-together-now')]
            public function postAllTogetherNow(): void {}
        });

        $routeWithNo = RouteInfo::exact(
            'PUT', '/no-override',
            #[AccessControl(allow: false)]
            function(): void {}
        );
        $routeWithNo->accessControl = new AccessControl; // allow: true
        $router->route($routeWithNo);

        $response = $router->handle(HttpRequest::createRequestWithoutBody('OPTIONS', '/nothing'));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
        $this->assertSame('GET, HEAD, OPTIONS', $response->getHeaderLine('Allow'));

        $response = $router->handle(HttpRequest::createRequestWithoutBody(
            'OPTIONS', '/nothing',
            [
                'Origin' => ['https://railgun.sh'],
                'Access-Control-Request-Method' => ['GET'],
            ],
        ));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin'));
        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));

        $response = $router->handle(HttpRequest::createRequestWithoutBody(
            'GET', '/nothing',
            [
                'Origin' => ['https://railgun.sh'],
            ],
        ));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertFalse($response->hasHeader('Content-Length'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin'));
        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));

        $response = $router->handle(HttpRequest::createRequestWithoutBody(
            'OPTIONS', '/no-override',
            [
                'Origin' => ['https://railgun.sh'],
                'Access-Control-Request-Method' => ['PUT'],
            ],
        ));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin'));
        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));

        $response = $router->handle(HttpRequest::createRequestWithoutBody(
            'PUT', '/no-override',
            [
                'Origin' => ['https://railgun.sh'],
            ],
        ));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertFalse($response->hasHeader('Content-Length'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin'));
        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));

        $response = $router->handle(HttpRequest::createRequestWithoutBody(
            'OPTIONS', '/acld',
            [
                'Origin' => ['https://railgun.sh'],
                'Access-Control-Request-Method' => ['GET'],
            ],
        ));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
        $this->assertSame('GET', $response->getHeaderLine('Access-Control-Allow-Methods'));
        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
        $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age'));

        $response = $router->handle(HttpRequest::createRequestWithoutBody(
            'GET', '/acld',
            [
                'Origin' => ['https://railgun.sh'],
            ],
        ));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertFalse($response->hasHeader('Content-Length'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));

        $response = $router->handle(HttpRequest::createRequestWithoutBody(
            'POST', '/acld',
            [
                'Origin' => ['https://railgun.sh'],
            ],
        ));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertFalse($response->hasHeader('Content-Length'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin'));
        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));

        $response = $router->handle(HttpRequest::createRequestWithoutBody(
            'OPTIONS', '/copdodnop',
            [
                'Origin' => ['https://railgun.sh'],
                'Access-Control-Request-Method' => ['GET'],
            ],
        ));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
        $this->assertSame('GET, POST', $response->getHeaderLine('Access-Control-Allow-Methods'));
        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
        $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age'));

        $response = $router->handle(HttpRequest::createRequestWithoutBody(
            'GET', '/copdodnop',
            [
                'Origin' => ['https://railgun.sh'],
            ],
        ));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertFalse($response->hasHeader('Content-Length'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));

        $response = $router->handle(HttpRequest::createRequestWithoutBody(
            'OPTIONS', '/copdodnop',
            [
                'Origin' => ['https://railgun.sh'],
                'Access-Control-Request-Method' => ['POST'],
            ],
        ));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
        $this->assertSame('true', $response->getHeaderLine('Access-Control-Allow-Credentials'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
        $this->assertSame('GET, POST', $response->getHeaderLine('Access-Control-Allow-Methods'));
        $this->assertSame('https://railgun.sh', $response->getHeaderLine('Access-Control-Allow-Origin'));
        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
        $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age'));

        $response = $router->handle(HttpRequest::createRequestWithoutBody(
            'POST', '/copdodnop',
            [
                'Origin' => ['https://railgun.sh'],
            ],
        ));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertFalse($response->hasHeader('Content-Length'));
        $this->assertSame('true', $response->getHeaderLine('Access-Control-Allow-Credentials'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
        $this->assertSame('https://railgun.sh', $response->getHeaderLine('Access-Control-Allow-Origin'));
        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));

        $response = $router->handle(HttpRequest::createRequestWithoutBody(
            'OPTIONS', '/copdodnop',
            [
                'Origin' => ['https://railgun.sh'],
                'Access-Control-Request-Method' => ['DELETE'],
            ],
        ));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
        $this->assertSame('GET, POST', $response->getHeaderLine('Access-Control-Allow-Methods'));
        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
        $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age'));

        $response = $router->handle(HttpRequest::createRequestWithoutBody(
            'DELETE', '/copdodnop',
            [
                'Origin' => ['https://railgun.sh'],
            ],
        ));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertFalse($response->hasHeader('Content-Length'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin'));
        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));

        $response = $router->handle(HttpRequest::createRequestWithoutBody(
            'OPTIONS', '/copdodnop',
            [
                'Origin' => ['https://railgun.sh'],
                'Access-Control-Request-Method' => ['PUT'],
            ],
        ));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
        $this->assertSame('GET, POST', $response->getHeaderLine('Access-Control-Allow-Methods'));
        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
        $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age'));

        $response = $router->handle(HttpRequest::createRequestWithoutBody(
            'PUT', '/copdodnop',
            [
                'Origin' => ['https://railgun.sh'],
            ],
        ));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertFalse($response->hasHeader('Content-Length'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin'));
        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));

        $response = $router->handle(HttpRequest::createRequestWithoutBody(
            'OPTIONS', '/methods/extra',
            [
                'Origin' => ['https://railgun.sh'],
                'Access-Control-Request-Method' => ['HEAD'],
            ],
        ));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
        $this->assertSame('GET, HEAD', $response->getHeaderLine('Access-Control-Allow-Methods'));
        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
        $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age'));

        $response = $router->handle(HttpRequest::createRequestWithoutBody(
            'GET', '/methods/extra',
            [
                'Origin' => ['https://railgun.sh'],
            ],
        ));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertFalse($response->hasHeader('Content-Length'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));

        $response = $router->handle(HttpRequest::createRequestWithoutBody(
            'HEAD', '/methods/extra',
            [
                'Origin' => ['https://railgun.sh'],
            ],
        ));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertFalse($response->hasHeader('Content-Length'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));

        $response = $router->handle(HttpRequest::createRequestWithoutBody(
            'OPTIONS', '/headers/allow-all',
            [
                'Origin' => ['https://railgun.sh'],
                'Access-Control-Request-Method' => ['GET'],
            ],
        ));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
        $this->assertSame('GET', $response->getHeaderLine('Access-Control-Allow-Methods'));
        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
        $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age'));

        $response = $router->handle(HttpRequest::createRequestWithoutBody(
            'OPTIONS', '/headers/allow-all',
            [
                'Origin' => ['https://railgun.sh'],
                'Access-Control-Request-Method' => ['GET'],
                'Access-Control-Request-Headers' => ['X-Soap'],
            ],
        ));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Headers'));
        $this->assertSame('GET', $response->getHeaderLine('Access-Control-Allow-Methods'));
        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
        $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age'));

        $response = $router->handle(HttpRequest::createRequestWithoutBody(
            'GET', '/headers/allow-all',
            [
                'Origin' => ['https://railgun.sh'],
            ],
        ));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertFalse($response->hasHeader('Content-Length'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));

        $response = $router->handle(HttpRequest::createRequestWithoutBody(
            'OPTIONS', '/headers/allow-all',
            [
                'Origin' => ['https://railgun.sh'],
                'Access-Control-Request-Method' => ['GET'],
                'Access-Control-Request-Headers' => [''],
            ],
        ));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Headers'));
        $this->assertSame('GET', $response->getHeaderLine('Access-Control-Allow-Methods'));
        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
        $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age'));

        $response = $router->handle(HttpRequest::createRequestWithoutBody(
            'GET', '/headers/allow-all',
            [
                'Origin' => ['https://railgun.sh'],
            ],
        ));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertFalse($response->hasHeader('Content-Length'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));

        $response = $router->handle(HttpRequest::createRequestWithoutBody(
            'OPTIONS', '/headers/allow-specific',
            [
                'Origin' => ['https://railgun.sh'],
                'Access-Control-Request-Method' => ['GET'],
            ],
        ));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
        $this->assertSame('GET', $response->getHeaderLine('Access-Control-Allow-Methods'));
        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
        $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age'));

        $response = $router->handle(HttpRequest::createRequestWithoutBody(
            'OPTIONS', '/headers/allow-specific',
            [
                'Origin' => ['https://railgun.sh'],
                'Access-Control-Request-Method' => ['GET'],
                'Access-Control-Request-Headers' => ['X-Content-Index', 'Authorization'],
            ],
        ));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
        $this->assertSame('Authorization, X-Content-Index', $response->getHeaderLine('Access-Control-Allow-Headers'));
        $this->assertSame('GET', $response->getHeaderLine('Access-Control-Allow-Methods'));
        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
        $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age'));

        $response = $router->handle(HttpRequest::createRequestWithoutBody(
            'OPTIONS', '/headers/allow-specific',
            [
                'Origin' => ['https://railgun.sh'],
                'Access-Control-Request-Method' => ['GET'],
                'Access-Control-Request-Headers' => ['X-Beans'],
            ],
        ));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin'));
        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));

        $response = $router->handle(HttpRequest::createRequestWithoutBody(
            'GET', '/headers/allow-specific',
            [
                'Origin' => ['https://railgun.sh'],
            ],
        ));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertFalse($response->hasHeader('Content-Length'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));

        $response = $router->handle(HttpRequest::createRequestWithoutBody(
            'OPTIONS', '/headers/expose-safe',
            [
                'Origin' => ['https://railgun.sh'],
                'Access-Control-Request-Method' => ['GET'],
            ],
        ));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
        $this->assertSame('GET', $response->getHeaderLine('Access-Control-Allow-Methods'));
        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
        $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age'));

        $response = $router->handle(HttpRequest::createRequestWithoutBody(
            'GET', '/headers/expose-safe',
            [
                'Origin' => ['https://railgun.sh'],
            ],
        ));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertFalse($response->hasHeader('Content-Length'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));

        $response = $router->handle(HttpRequest::createRequestWithoutBody(
            'OPTIONS', '/headers/expose-all',
            [
                'Origin' => ['https://railgun.sh'],
                'Access-Control-Request-Method' => ['GET'],
            ],
        ));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
        $this->assertSame('GET', $response->getHeaderLine('Access-Control-Allow-Methods'));
        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
        $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age'));

        $response = $router->handle(HttpRequest::createRequestWithoutBody(
            'GET', '/headers/expose-all',
            [
                'Origin' => ['https://railgun.sh'],
            ],
        ));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertFalse($response->hasHeader('Content-Length'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
        $this->assertTrue($response->hasHeader('Access-Control-Expose-Headers'));
        $this->assertSame('*', $response->getHeaderLine('Access-Control-Expose-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));

        $response = $router->handle(HttpRequest::createRequestWithoutBody(
            'OPTIONS', '/headers/expose-specific',
            [
                'Origin' => ['https://railgun.sh'],
                'Access-Control-Request-Method' => ['GET'],
            ],
        ));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
        $this->assertSame('GET', $response->getHeaderLine('Access-Control-Allow-Methods'));
        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
        $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age'));

        $response = $router->handle(HttpRequest::createRequestWithoutBody(
            'GET', '/headers/expose-specific',
            [
                'Origin' => ['https://railgun.sh'],
            ],
        ));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertFalse($response->hasHeader('Content-Length'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
        $this->assertTrue($response->hasHeader('Access-Control-Expose-Headers'));
        $this->assertSame('Vary, X-EEPROM-Max-Size', $response->getHeaderLine('Access-Control-Expose-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));

        $response = $router->handle(HttpRequest::createRequestWithoutBody(
            'OPTIONS', '/all-together-now',
            [
                'Origin' => ['https://flash.moe:8443'],
                'Access-Control-Request-Method' => ['GET'],
            ],
        ));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
        $this->assertSame('true', $response->getHeaderLine('Access-Control-Allow-Credentials'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
        $this->assertSame('GET, HEAD, POST', $response->getHeaderLine('Access-Control-Allow-Methods'));
        $this->assertSame('https://flash.moe:8443', $response->getHeaderLine('Access-Control-Allow-Origin'));
        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
        $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age'));

        $response = $router->handle(HttpRequest::createRequestWithoutBody(
            'OPTIONS', '/all-together-now',
            [
                'Origin' => ['https://flash.moe:8443'],
                'Access-Control-Request-Method' => ['GET'],
                'Access-Control-Request-Headers' => [''],
            ],
        ));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
        $this->assertSame('true', $response->getHeaderLine('Access-Control-Allow-Credentials'));
        $this->assertTrue($response->hasHeader('Access-Control-Allow-Headers'));
        $this->assertSame('', $response->getHeaderLine('Access-Control-Allow-Headers'));
        $this->assertSame('GET, HEAD, POST', $response->getHeaderLine('Access-Control-Allow-Methods'));
        $this->assertSame('https://flash.moe:8443', $response->getHeaderLine('Access-Control-Allow-Origin'));
        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
        $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age'));

        $response = $router->handle(HttpRequest::createRequestWithoutBody(
            'OPTIONS', '/all-together-now',
            [
                'Origin' => ['https://flash.moe:8443'],
                'Access-Control-Request-Method' => ['GET'],
                'Access-Control-Request-Headers' => ['x-content-index'],
            ],
        ));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
        $this->assertSame('true', $response->getHeaderLine('Access-Control-Allow-Credentials'));
        $this->assertSame('x-content-index', $response->getHeaderLine('Access-Control-Allow-Headers'));
        $this->assertSame('GET, HEAD, POST', $response->getHeaderLine('Access-Control-Allow-Methods'));
        $this->assertSame('https://flash.moe:8443', $response->getHeaderLine('Access-Control-Allow-Origin'));
        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
        $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age'));

        $response = $router->handle(HttpRequest::createRequestWithoutBody(
            'GET', '/all-together-now',
            [
                'Origin' => ['https://flash.moe:8443'],
            ],
        ));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertFalse($response->hasHeader('Content-Length'));
        $this->assertSame('true', $response->getHeaderLine('Access-Control-Allow-Credentials'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
        $this->assertSame('https://flash.moe:8443', $response->getHeaderLine('Access-Control-Allow-Origin'));
        $this->assertSame('X-Satori-Hash, X-Satori-Time', $response->getHeaderLine('Access-Control-Expose-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));

        // although routes can declare other methods than their own as CORS supported,
        // allow credentials and headers ONLY apply to their own method
        $response = $router->handle(HttpRequest::createRequestWithoutBody(
            'OPTIONS', '/all-together-now',
            [
                'Origin' => ['https://flash.moe:8443'],
                'Access-Control-Request-Method' => ['POST'],
            ],
        ));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
        $this->assertSame('GET, HEAD, POST', $response->getHeaderLine('Access-Control-Allow-Methods'));
        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
        $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age'));

        $response = $router->handle(HttpRequest::createRequestWithoutBody(
            'POST', '/all-together-now',
            [
                'Origin' => ['https://flash.moe:8443'],
            ],
        ));
        $this->assertSame(200, $response->getStatusCode());
        $this->assertFalse($response->hasHeader('Content-Length'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
        $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin'));
        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));
    }
}