| 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> |
2 days 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> |
2 days 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/ | |||
| .DS_Store | |||
| 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')); | |||
| } | |||
| } | |||