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'));
+ }
+
+}