2 Commits

Author SHA1 Message Date
  Aaron Parecki 730445f34d Add a test suite and run it on GitHub Actions across PHP versions 2 days ago
  Aaron Parecki 96abbb3c18 Detect RSS from the Content-Type of the HEAD response 2 days ago
9 changed files with 414 additions and 1 deletions
Split View
  1. +44
    -0
      .github/workflows/php.yml
  2. +2
    -0
      .gitignore
  3. +7
    -0
      composer.json
  4. +8
    -0
      phpunit.xml
  5. +1
    -1
      src/p3k/WebSub/Client.php
  6. +215
    -0
      tests/DiscoverTest.php
  7. +38
    -0
      tests/FakeHTTP.php
  8. +54
    -0
      tests/SubscribeTest.php
  9. +45
    -0
      tests/VerifySignatureTest.php

+ 44
- 0
.github/workflows/php.yml View File

@ -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

+ 2
- 0
.gitignore View File

@ -1,3 +1,5 @@
vendor/
.DS_Store
composer.lock
.phpunit.result.cache
.phpunit.cache/

+ 7
- 0
composer.json View File

@ -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"
}
}

+ 8
- 0
phpunit.xml View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php" colors="true">
<testsuites>
<testsuite name="p3k-websub">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>

+ 1
- 1
src/p3k/WebSub/Client.php View File

@ -56,7 +56,7 @@ class Client {
if(strpos($content_type, 'text/html') !== false) {
$type = $http['type'] = 'html';
} else if(strpos($content_type, 'xml') !== false) {
if(strpos('rss', $content_type) !== false) {
if(strpos($content_type, 'rss') !== false) {
$type = $http['type'] = 'rss';
} else if(strpos($content_type, 'atom') !== false) {
$type = $http['type'] = 'atom';

+ 215
- 0
tests/DiscoverTest.php View File

@ -0,0 +1,215 @@
<?php
namespace p3k\WebSub\Tests;
use PHPUnit\Framework\TestCase;
use p3k\WebSub\Client;
class DiscoverTest extends TestCase {
public function testFindsHubAndSelfInHeadRequestLinkHeaders() {
$http = new FakeHTTP([
'HEAD' => [
'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' => '<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example</title>
<link rel="hub" href="https://hub.example/"/>
<link rel="self" href="https://example.com/feed.atom"/>
</feed>',
],
]);
$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' => '<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>Example</title>
<atom:link rel="hub" href="https://hub.example/"/>
<atom:link rel="self" href="https://example.com/feed.rss"/>
</channel>
</rss>',
],
]);
$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' => '<!doctype html>
<html>
<head>
<title>Example</title>
<link rel="hub" href="https://hub.example/">
<link rel="self" href="https://example.com/">
</head>
<body></body>
</html>',
],
]);
$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' => '<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<link rel="hub" href="https://body-hub.example/"/>
<link rel="self" href="https://example.com/feed.atom"/>
</feed>',
],
]);
$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']);
}
}

+ 38
- 0
tests/FakeHTTP.php View File

@ -0,0 +1,38 @@
<?php
namespace p3k\WebSub\Tests;
// Stands in for p3k\HTTP: returns canned responses and records each request
class FakeHTTP {
public $requests = [];
private $responses;
public function __construct($responses=[]) {
$this->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);
}
}

+ 54
- 0
tests/SubscribeTest.php View File

@ -0,0 +1,54 @@
<?php
namespace p3k\WebSub\Tests;
use PHPUnit\Framework\TestCase;
use p3k\WebSub\Client;
class SubscribeTest extends TestCase {
public function testSubscribePostsFormEncodedRequest() {
$http = new FakeHTTP(['POST' => ['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']
);
}
}

+ 45
- 0
tests/VerifySignatureTest.php View File

@ -0,0 +1,45 @@
<?php
namespace p3k\WebSub\Tests;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\DataProvider;
use p3k\WebSub\Client;
class VerifySignatureTest extends TestCase {
public static function signatureAlgorithms() {
return [['sha1'], ['sha256'], ['sha384'], ['sha512']];
}
/**
* @dataProvider signatureAlgorithms
*/
#[DataProvider('signatureAlgorithms')]
public function testAcceptsValidSignature($alg) {
$body = '{"hello":"world"}';
$header = $alg.'='.hash_hmac($alg, $body, 's3cret');
$this->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'));
}
}

Loading…
Cancel
Save