| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
730445f34d |
Add a test suite and run it on GitHub Actions across PHP versions
Tests use a fake HTTP client injected into Client to cover discovery (Link headers, Atom, RSS and HTML bodies), subscribe/unsubscribe requests, and signature verification. The workflow is adapted from p3k-http's and runs on PHP 7.3 through 8.4, since PHPUnit 9.6 needs 7.3. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> |
1 day ago |
|
|
96abbb3c18 |
Detect RSS from the Content-Type of the HEAD response
The strpos arguments were reversed, so a HEAD response with an application/rss+xml Content-Type was never reported as rss. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> |
1 day ago |
| @ -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 | |||||
| @ -1,3 +1,5 @@ | |||||
| vendor/ | vendor/ | ||||
| .DS_Store | .DS_Store | ||||
| composer.lock | composer.lock | ||||
| .phpunit.result.cache | |||||
| .phpunit.cache/ | |||||
| @ -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> | |||||
| @ -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']); | |||||
| } | |||||
| } | |||||
| @ -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); | |||||
| } | |||||
| } | |||||
| @ -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'] | |||||
| ); | |||||
| } | |||||
| } | |||||
| @ -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')); | |||||
| } | |||||
| } | |||||