diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml new file mode 100644 index 0000000..0865e88 --- /dev/null +++ b/.github/workflows/php.yml @@ -0,0 +1,44 @@ +name: PHP Composer + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + build: + strategy: + matrix: + # PHPUnit 9.6 needs PHP 7.3 or later + php-versions: ["7.3", "7.4", "8.0", "8.1", "8.2", "8.3", "8.4"] + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP, with composer and extensions + uses: shivammathur/setup-php@v2 #https://github.com/shivammathur/setup-php + with: + php-version: ${{ matrix.php-versions }} + extensions: mbstring, dom, curl + coverage: none + + - name: Validate composer.json + run: composer validate + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v4 + with: + path: vendor + key: ${{ runner.os }}-php-${{ matrix.php-versions }}-${{ hashFiles('composer.json') }} + restore-keys: | + ${{ runner.os }}-php-${{ matrix.php-versions }}- + + # There's no committed composer.lock, so resolve for each PHP version + - name: Install dependencies + run: composer update --prefer-dist --no-progress + + - name: Run test suite + run: composer run-script test diff --git a/.gitignore b/.gitignore index 8622dcb..0e44e9c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ vendor/ .DS_Store composer.lock +.phpunit.result.cache +.phpunit.cache/ diff --git a/composer.json b/composer.json index 627c217..b4a01a6 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,7 @@ "p3k/utils": "1.*" }, "require-dev": { + "phpunit/phpunit": "^9.6 || ^10 || ^11", "predis/predis": "1.*" }, "autoload": { @@ -25,5 +26,11 @@ } }, "autoload-dev": { + "psr-4": { + "p3k\\WebSub\\Tests\\": "tests/" + } + }, + "scripts": { + "test": "phpunit" } } diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..1d16f4e --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,8 @@ + + + + + tests + + + diff --git a/tests/DiscoverTest.php b/tests/DiscoverTest.php new file mode 100644 index 0000000..98f761c --- /dev/null +++ b/tests/DiscoverTest.php @@ -0,0 +1,215 @@ + [ + 'headers' => ['Content-Type' => 'text/html; charset=utf-8'], + 'rels' => [ + 'hub' => ['https://hub.example/'], + 'self' => ['https://example.com/'], + ], + ], + ]); + $client = new Client($http); + + $result = $client->discover('https://example.com/'); + + $this->assertSame([ + 'hub' => 'https://hub.example/', + 'hub_source' => 'http', + 'self' => 'https://example.com/', + 'self_source' => 'http', + 'type' => 'html', + ], $result); + // Everything was in the HEAD response, so no GET is needed + $this->assertCount(1, $http->requests); + $this->assertSame('HEAD', $http->requests[0]['method']); + } + + public function testFallsBackToGetWhenHeadHasNoLinks() { + $http = new FakeHTTP([ + 'GET' => [ + 'headers' => ['Content-Type' => 'text/html'], + 'rels' => [ + 'hub' => ['https://hub.example/'], + 'self' => ['https://example.com/'], + ], + ], + ]); + $client = new Client($http); + + $result = $client->discover('https://example.com/'); + + $this->assertSame('https://hub.example/', $result['hub']); + $this->assertSame('http', $result['hub_source']); + $this->assertSame(['HEAD', 'GET'], array_column($http->requests, 'method')); + } + + public function testSkipsHeadRequestWhenHeadfirstIsFalse() { + $http = new FakeHTTP([ + 'GET' => [ + 'headers' => ['Content-Type' => 'text/html'], + 'rels' => [ + 'hub' => ['https://hub.example/'], + 'self' => ['https://example.com/'], + ], + ], + ]); + $client = new Client($http); + + $client->discover('https://example.com/', false); + + $this->assertSame(['GET'], array_column($http->requests, 'method')); + } + + public function testDetectsRssFromHeadContentType() { + $http = new FakeHTTP([ + 'HEAD' => [ + 'headers' => ['Content-Type' => 'application/rss+xml; charset=utf-8'], + 'rels' => [ + 'hub' => ['https://hub.example/'], + 'self' => ['https://example.com/feed.rss'], + ], + ], + ]); + $client = new Client($http); + + $result = $client->discover('https://example.com/feed.rss'); + + $this->assertSame('rss', $result['type']); + $this->assertCount(1, $http->requests); + } + + public function testFindsLinksInAtomFeedBody() { + $http = new FakeHTTP([ + 'GET' => [ + 'headers' => ['Content-Type' => 'application/atom+xml'], + 'body' => ' + + Example + + +', + ], + ]); + $client = new Client($http); + + $result = $client->discover('https://example.com/feed.atom'); + + $this->assertSame([ + 'hub' => 'https://hub.example/', + 'hub_source' => 'body', + 'self' => 'https://example.com/feed.atom', + 'self_source' => 'body', + 'type' => 'atom', + ], $result); + } + + public function testFindsAtomLinksInRssFeedBody() { + $http = new FakeHTTP([ + 'GET' => [ + 'headers' => ['Content-Type' => 'application/rss+xml'], + 'body' => ' + + + Example + + + +', + ], + ]); + $client = new Client($http); + + $result = $client->discover('https://example.com/feed.rss'); + + $this->assertSame('https://hub.example/', $result['hub']); + $this->assertSame('https://example.com/feed.rss', $result['self']); + $this->assertSame('body', $result['self_source']); + $this->assertSame('rss', $result['type']); + } + + public function testFindsLinkElementsInHtmlBody() { + $http = new FakeHTTP([ + 'GET' => [ + 'headers' => ['Content-Type' => 'text/html'], + 'body' => ' + + + Example + + + + +', + ], + ]); + $client = new Client($http); + + $result = $client->discover('https://example.com/'); + + $this->assertSame('https://hub.example/', $result['hub']); + $this->assertSame('body', $result['hub_source']); + $this->assertSame('https://example.com/', $result['self']); + $this->assertSame('html', $result['type']); + } + + public function testLinkHeadersTakePriorityOverBody() { + $http = new FakeHTTP([ + 'GET' => [ + 'headers' => ['Content-Type' => 'application/atom+xml'], + 'rels' => ['hub' => ['https://header-hub.example/']], + 'body' => ' + + + +', + ], + ]); + $client = new Client($http); + + $result = $client->discover('https://example.com/feed.atom'); + + $this->assertSame('https://header-hub.example/', $result['hub']); + $this->assertSame('http', $result['hub_source']); + $this->assertSame('https://example.com/feed.atom', $result['self']); + $this->assertSame('body', $result['self_source']); + } + + public function testReturnsFalseWithoutHub() { + $http = new FakeHTTP([ + 'GET' => [ + 'headers' => ['Content-Type' => 'text/html'], + 'rels' => ['self' => ['https://example.com/']], + ], + ]); + $client = new Client($http); + + $this->assertFalse($client->discover('https://example.com/')); + } + + public function testVerboseIncludesDetails() { + $http = new FakeHTTP([ + 'HEAD' => [ + 'headers' => ['Content-Type' => 'text/html'], + 'rels' => [ + 'hub' => ['https://hub.example/'], + 'self' => ['https://example.com/'], + ], + ], + ]); + $client = new Client($http); + + $result = $client->discover('https://example.com/', true, true); + + $this->assertSame(['https://hub.example/'], $result['details']['http']['hub']); + $this->assertSame([], $result['details']['body']['hub']); + } + +} diff --git a/tests/FakeHTTP.php b/tests/FakeHTTP.php new file mode 100644 index 0000000..6725033 --- /dev/null +++ b/tests/FakeHTTP.php @@ -0,0 +1,38 @@ +responses = $responses; + } + + public function head($url) { + return $this->respond('HEAD', $url); + } + + public function get($url) { + return $this->respond('GET', $url); + } + + public function post($url, $body) { + return $this->respond('POST', $url, $body); + } + + private function respond($method, $url, $body=null) { + $this->requests[] = ['method' => $method, 'url' => $url, 'body' => $body]; + + $response = isset($this->responses[$method]) ? $this->responses[$method] : []; + return array_merge([ + 'code' => 200, + 'headers' => [], + 'rels' => [], + 'body' => '', + ], $response); + } + +} diff --git a/tests/SubscribeTest.php b/tests/SubscribeTest.php new file mode 100644 index 0000000..9854ef3 --- /dev/null +++ b/tests/SubscribeTest.php @@ -0,0 +1,54 @@ + ['code' => 202]]); + $client = new Client($http); + + $response = $client->subscribe('https://hub.example/', 'https://example.com/feed', 'https://reader.example/callback'); + + $this->assertSame(202, $response['code']); + $this->assertCount(1, $http->requests); + $this->assertSame('https://hub.example/', $http->requests[0]['url']); + parse_str($http->requests[0]['body'], $params); + $this->assertSame([ + 'hub_mode' => 'subscribe', + 'hub_topic' => 'https://example.com/feed', + 'hub_callback' => 'https://reader.example/callback', + ], $params); + $this->assertStringContainsString('hub.mode=subscribe', $http->requests[0]['body']); + } + + public function testSubscribeIncludesLeaseSecondsAndSecret() { + $http = new FakeHTTP(); + $client = new Client($http); + + $client->subscribe('https://hub.example/', 'https://example.com/feed', 'https://reader.example/callback', [ + 'lease_seconds' => 86400, + 'secret' => 's3cret', + ]); + + $body = $http->requests[0]['body']; + $this->assertStringContainsString('hub.lease_seconds=86400', $body); + $this->assertStringContainsString('hub.secret=s3cret', $body); + } + + public function testUnsubscribePostsFormEncodedRequest() { + $http = new FakeHTTP(); + $client = new Client($http); + + $client->unsubscribe('https://hub.example/', 'https://example.com/feed', 'https://reader.example/callback'); + + $this->assertSame('https://hub.example/', $http->requests[0]['url']); + $this->assertSame( + 'hub.mode=unsubscribe&hub.topic=https%3A%2F%2Fexample.com%2Ffeed&hub.callback=https%3A%2F%2Freader.example%2Fcallback', + $http->requests[0]['body'] + ); + } + +} diff --git a/tests/VerifySignatureTest.php b/tests/VerifySignatureTest.php new file mode 100644 index 0000000..ce0ce14 --- /dev/null +++ b/tests/VerifySignatureTest.php @@ -0,0 +1,45 @@ +assertTrue(Client::verify_signature($body, $header, 's3cret')); + } + + public function testRejectsWrongSecret() { + $body = '{"hello":"world"}'; + $header = 'sha256='.hash_hmac('sha256', $body, 's3cret'); + + $this->assertFalse(Client::verify_signature($body, $header, 'wrong')); + } + + public function testRejectsModifiedBody() { + $header = 'sha256='.hash_hmac('sha256', 'original', 's3cret'); + + $this->assertFalse(Client::verify_signature('modified', $header, 's3cret')); + } + + public function testRejectsMissingOrMalformedHeader() { + $this->assertFalse(Client::verify_signature('body', null, 's3cret')); + $this->assertFalse(Client::verify_signature('body', '', 's3cret')); + $this->assertFalse(Client::verify_signature('body', 'md5=abc', 's3cret')); + $this->assertFalse(Client::verify_signature('body', ['sha256=abc'], 's3cret')); + } + +}