diff --git a/tests/Unit/RequestTest.php b/tests/Unit/RequestTest.php new file mode 100644 index 00000000..5732f86d --- /dev/null +++ b/tests/Unit/RequestTest.php @@ -0,0 +1,148 @@ + '1', 'shared' => 'fromGet'], ['b' => '2', 'shared' => 'fromPost']); + + $this->assertSame('fromGet', $r->get('shared'), 'get() reads query only'); + $this->assertSame('fromPost', $r->post('shared'), 'post() reads post only'); + $this->assertSame('fromPost', $r->input('shared'), 'merged input: POST wins'); + $this->assertSame('1', $r->input('a')); + $this->assertSame('2', $r->input('b')); + } + + public function testInputDefaultsHasAndAll(): void { + $r = new Request(['a' => '1'], ['b' => '2']); + + $this->assertNull($r->input('missing')); + $this->assertSame('fallback', $r->input('missing', 'fallback')); + $this->assertTrue($r->has('a')); + $this->assertFalse($r->has('missing')); + $this->assertSame(['a' => '1', 'b' => '2'], $r->all()); + } + + public function testGetIntCoercesValues(): void { + $r = new Request([], ['n' => '42abc', 'clean' => '7']); + + $this->assertSame(42, $r->getInt('n')); + $this->assertSame(7, $r->getInt('clean')); + $this->assertSame(0, $r->getInt('missing')); + $this->assertSame(99, $r->getInt('missing', 99)); + } + + public function testGetBoolCoercesValues(): void { + $r = new Request([], ['on' => 'true', 'num' => '1', 'off' => '0', 'word' => 'no']); + + $this->assertTrue($r->getBool('on')); + $this->assertTrue($r->getBool('num')); + $this->assertFalse($r->getBool('off')); + $this->assertFalse($r->getBool('word')); + $this->assertFalse($r->getBool('missing')); + $this->assertTrue($r->getBool('missing', true)); + } + + public function testServerAccessorsAndRequestHelpers(): void { + $r = new Request([], [], [ + 'REQUEST_METHOD' => 'POST', + 'HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest', + 'REQUEST_URI' => '/admin/index.php', + 'HTTP_USER_AGENT' => 'UnitAgent/1.0', + ], ['sid' => 'abc']); + + $this->assertSame('POST', $r->method()); + $this->assertTrue($r->isPost()); + $this->assertTrue($r->isAjax(), 'case-insensitive XMLHttpRequest'); + $this->assertSame('/admin/index.php', $r->uri()); + $this->assertSame('UnitAgent/1.0', $r->userAgent()); + $this->assertSame('abc', $r->cookie('sid')); + $this->assertNull($r->cookie('missing')); + } + + public function testDefaultsWhenServerIsEmpty(): void { + $r = new Request(); + + $this->assertSame('GET', $r->method()); + $this->assertFalse($r->isPost()); + $this->assertFalse($r->isAjax()); + $this->assertSame('/', $r->uri()); + $this->assertSame('', $r->userAgent()); + $this->assertSame('', $r->host()); + } + + public function testHostPrefersHttpHostThenServerName(): void { + $this->assertSame('example.tv', (new Request([], [], ['HTTP_HOST' => 'example.tv', 'SERVER_NAME' => 'internal']))->host()); + $this->assertSame('internal', (new Request([], [], ['SERVER_NAME' => 'internal']))->host()); + } + + public function testClientIpTakesFirstValidForwardedForEntry(): void { + $r = new Request([], [], ['HTTP_X_FORWARDED_FOR' => '203.0.113.5, 10.0.0.1', 'REMOTE_ADDR' => '10.0.0.1']); + $this->assertSame('203.0.113.5', $r->ip()); + } + + public function testClientIpSkipsInvalidHeadersAndFallsBack(): void { + $withRealIp = new Request([], [], ['HTTP_X_FORWARDED_FOR' => 'not-an-ip', 'HTTP_X_REAL_IP' => '198.51.100.7']); + $this->assertSame('198.51.100.7', $withRealIp->ip(), 'invalid XFF skipped, X-Real-IP used'); + + $none = new Request([], [], []); + $this->assertSame('0.0.0.0', $none->ip(), 'no usable header → sentinel'); + } + + // ── Sanitization (security-relevant) ───────────────────────────── + + public function testConstructorStripsNullBytesAndNeutralisesTraversal(): void { + $r = new Request(['path' => "../secret\0.txt"]); + + $value = $r->input('path'); + $this->assertStringNotContainsString(chr(0), $value, 'NUL byte removed'); + $this->assertStringNotContainsString('../', $value, 'traversal neutralised'); + $this->assertStringContainsString('../', $value); + } + + public function testParseCleanValueNeutralisesHtmlPayloads(): void { + $this->assertStringNotContainsString('alert(1)')); + $this->assertStringContainsString('<script', Request::parseCleanValue(''), 'case-insensitive'); + + $comment = Request::parseCleanValue('ab'); + $this->assertStringNotContainsString('', $comment); + + $this->assertSame("O'Brien", Request::parseCleanValue("O\\'Brien"), 'stripslashes'); + $this->assertSame("a\nb", Request::parseCleanValue("a\r\nb"), 'CRLF normalised to LF'); + $this->assertSame('trimmed', Request::parseCleanValue(' trimmed ')); + $this->assertSame('', Request::parseCleanValue('')); + } + + public function testParseCleanKeySanitises(): void { + $this->assertSame('page_id', Request::parseCleanKey('page_id'), 'valid key preserved'); + $this->assertSame('username', Request::parseCleanKey('user..name'), 'double-dot stripped'); + $this->assertSame('', Request::parseCleanKey('__proto__'), '__x__ pattern removed'); + $this->assertStringStartsWith('a<b', Request::parseCleanKey('aassertSame('', Request::parseCleanKey('')); + } + + public function testCleanGlobalsScrubsRecursivelyInPlace(): void { + $data = [ + 'nul' => "a\0b", + 'rtl' => 'x‮y', + 'deep' => ['trav' => '../etc/passwd'], + ]; + Request::cleanGlobals($data); + + $this->assertSame('ab', $data['nul']); + $this->assertSame('xy', $data['rtl'], 'RTL override removed'); + $this->assertSame('../etc/passwd', $data['deep']['trav']); + } +}