Files
xExtension-BlueskyThreads/tests/BlueskyThreadsTest.php
T
KydoimosandClaude Opus 5 583c489074
CI / test (pull_request) Successful in 40s
Use cURL for Bluesky API calls instead of file_get_contents
FreshRSS 1.30.0 added unregister_unsafe_protocols() to lib/lib_rss.php,
which unregisters every PHP stream wrapper except file:// and php:// at
boot as an SSRF mitigation. That removed the https:// wrapper, so the
stream-context-based fetch in apiGet() stopped working entirely — every
API call failed with "Unable to find the wrapper https".

Because the call site used @file_get_contents(), the failure was silent:
apiGet() returned null, loadThread() fell through to its null fallback,
and entries were saved with their unenriched RSS content. The visible
symptom was Bluesky posts rendering with no thread and no embeds, with
nothing in the logs to explain it.

Switch apiGet() to cURL, which does not go through stream wrappers (and
is how FreshRSS fetches feeds itself, which is why feed retrieval kept
working throughout). Connect timeout added and redirect-following
explicitly disabled — a public XRPC endpoint has no reason to redirect,
and following redirects would reopen an SSRF vector.

Also stop swallowing failures: transport errors, non-2xx responses and
malformed JSON are now reported via Minz_Log::warning() so this class of
breakage is visible next time.

Tests: add testApiGetWorksWithoutHttpsStreamWrapper, which unregisters
the https wrapper before calling the API and so fails against the old
implementation, plus testApiGetLogsWarningOnFailure. Add a Minz_Log stub
and curl to the CI extension list.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-10 10:32:55 -03:00

373 lines
14 KiB
PHP

<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Tests for BlueskyThreadsExtension.
*
* Private methods are exercised via ReflectionMethod so the public API of the
* extension stays untouched while still allowing granular unit coverage.
*/
class BlueskyThreadsTest extends TestCase {
private BlueskyThreadsExtension $ext;
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
protected function setUp(): void {
Minz_Request::reset();
Minz_Log::reset();
$this->ext = new BlueskyThreadsExtension();
}
/** Call a private/protected method by name and return its result. */
private function call(string $method, mixed ...$args): mixed {
$ref = new ReflectionMethod(BlueskyThreadsExtension::class, $method);
return $ref->invoke($this->ext, ...$args);
}
// -------------------------------------------------------------------------
// Feed parsing — verifies the extension detects Bluesky posts in a real feed
// -------------------------------------------------------------------------
/**
* Every <link> in the hockeyviz RSS feed must match the Bluesky post URL
* pattern used by the extension, confirming the feed is fully covered.
*
* Feed source: https://bsky.app/profile/did:plc:d4324t32vfi5xzydqbh2qdj3/rss
*/
public function testAllFeedLinksMatchBskyPattern(): void {
$feed = simplexml_load_file(__DIR__ . '/fixtures/hockeyviz_feed.xml');
$pattern = '~bsky\.app/profile/([^/]+)/post/([^/?#\s]+)~';
$this->assertNotFalse($feed, 'RSS fixture must be parseable XML');
$this->assertGreaterThan(0, count($feed->channel->item), 'Feed must have at least one item');
foreach ($feed->channel->item as $item) {
$this->assertMatchesRegularExpression(
$pattern,
(string) $item->link,
"Link '{$item->link}' should match the Bluesky post URL pattern"
);
}
}
/**
* The specific thread post (2 replies) is present in the feed and its URL
* is correctly identified as a Bluesky post by the extension's pattern.
*/
public function testThreadPostIsPresentInFeed(): void {
$feed = simplexml_load_file(__DIR__ . '/fixtures/hockeyviz_feed.xml');
$threadPostLink = 'https://bsky.app/profile/hockeyviz.com/post/3mhtk7awhrp26';
$found = false;
foreach ($feed->channel->item as $item) {
if ((string) $item->link === $threadPostLink) {
$found = true;
break;
}
}
$this->assertTrue($found, "Thread post {$threadPostLink} must be present in the feed fixture");
}
/**
* fetchThread() leaves entries whose link is not a Bluesky URL completely
* untouched — no content mutation should occur.
*/
public function testFetchThreadIgnoresNonBskyEntries(): void {
$entry = new FreshRSS_Entry();
$entry->_link('https://example.com/some/article');
$entry->_content('original content');
$result = $this->ext->fetchThread($entry);
$this->assertSame('original content', $result->content());
}
/**
* fetchThread() recognises a valid Bluesky post URL and attempts enrichment.
* We verify it does NOT leave the content untouched (even if the API fails
* in CI, the URL detection code-path must be exercised).
*
* Because this calls the live API (or silently skips on failure), we just
* assert the entry is returned without throwing.
*/
public function testFetchThreadHandlesBskyEntryWithoutThrowing(): void {
$entry = new FreshRSS_Entry();
$entry->_link('https://bsky.app/profile/hockeyviz.com/post/3mhtk7awhrp26');
$entry->_content('');
$result = $this->ext->fetchThread($entry);
$this->assertInstanceOf(FreshRSS_Entry::class, $result);
}
// -------------------------------------------------------------------------
// Thread rendering — given a fixture API response, HTML must be correct
// -------------------------------------------------------------------------
/**
* renderThread() on the 3mhtk7awhrp26 fixture (1 root + 2 replies) must
* produce exactly 3 bsky-post elements and 1 bsky-replies wrapper.
*/
public function testRenderThreadWithTwoReplies(): void {
$data = json_decode(file_get_contents(__DIR__ . '/fixtures/thread_3mhtk7awhrp26.json'), true);
$thread = $data['thread'];
$html = $this->call('renderThread', $thread, true);
$this->assertSame(3, substr_count($html, 'bsky-post'),
'Should render root post + 2 replies = 3 bsky-post elements');
$this->assertSame(1, substr_count($html, 'bsky-replies'),
'Should wrap replies in exactly one bsky-replies container');
}
/** Root post carries the bordered card style; replies use lighter padding. */
public function testRootPostUsesCardStyle(): void {
$data = json_decode(file_get_contents(__DIR__ . '/fixtures/thread_3mhtk7awhrp26.json'), true);
$html = $this->call('renderThread', $data['thread'], true);
$this->assertStringContainsString('border:1px solid #cfd9de;border-radius:12px', $html,
'Root post must use the card border style');
}
/** The root post text must appear in the rendered HTML. */
public function testRootPostTextIsRendered(): void {
$data = json_decode(file_get_contents(__DIR__ . '/fixtures/thread_3mhtk7awhrp26.json'), true);
$html = $this->call('renderThread', $data['thread'], true);
$this->assertStringContainsString('Gold Drafting', $html);
$this->assertStringContainsString('hockeyviz.com', $html);
}
/** Reply post authors are not rendered; only quoted post authors are. */
public function testReplyAuthorsAreRendered(): void {
$data = json_decode(file_get_contents(__DIR__ . '/fixtures/thread_3mhtk7awhrp26.json'), true);
$html = $this->call('renderThread', $data['thread'], true);
$this->assertStringNotContainsString('example.bsky.social', $html, 'Reply author must not appear in thread posts');
$this->assertStringNotContainsString('hockeyfan.bsky.social', $html, 'Reply author must not appear in thread posts');
}
/** The quoted/embedded post inside the root is rendered via renderQuotedRecord. */
public function testQuotedPostEmbedIsRendered(): void {
$data = json_decode(file_get_contents(__DIR__ . '/fixtures/thread_3mhtk7awhrp26.json'), true);
$html = $this->call('renderThread', $data['thread'], true);
// The quoted record contains the #GoldRace text
$this->assertStringContainsString('GoldRace', $html,
'Quoted post text must be rendered as an embed');
}
/** Quoted posts use a blockquote with a handle permalink; thread posts have no permalink. */
public function testViewOnBskyLinksArePresent(): void {
$data = json_decode(file_get_contents(__DIR__ . '/fixtures/thread_3mhtk7awhrp26.json'), true);
$html = $this->call('renderThread', $data['thread'], true);
$this->assertStringNotContainsString('3mhtk7awhrp26', $html, 'Thread post rkey must not appear as a permalink');
$this->assertStringContainsString('3mhtjo3rtkn26', $html, 'Quoted post rkey must appear in the handle permalink');
$this->assertStringContainsString('<blockquote', $html, 'Quoted post must be wrapped in a blockquote');
$this->assertStringNotContainsString('View on Bluesky', $html);
}
/**
* A post with only a reply from another user (3mhypinzezo2l fixture) must
* render just the root post — the other-author reply is filtered out.
*/
public function testOtherAuthorRepliesAreExcluded(): void {
$data = json_decode(file_get_contents(__DIR__ . '/fixtures/thread_3mhypinzezo2l.json'), true);
$thread = $data['thread'];
$rootAuthorDid = $thread['post']['author']['did'];
$html = $this->call('renderThread', $thread, true, $rootAuthorDid);
$this->assertSame(1, substr_count($html, 'bsky-post'),
'Only the root post should be rendered; other-author reply must be excluded');
$this->assertSame(0, substr_count($html, 'bsky-replies'),
'No replies wrapper should be rendered when all replies are from other authors');
$this->assertStringNotContainsString('lasagnazero.bsky.social', $html,
'Reply author handle must not appear in output');
}
// -------------------------------------------------------------------------
// applyFacets — rich-text rendering
// -------------------------------------------------------------------------
public function testApplyFacetsNoFacetsEscapesHtml(): void {
$result = $this->call('applyFacets', '<Hello & World>', []);
$this->assertSame('&lt;Hello &amp; World&gt;', $result);
}
public function testApplyFacetsRendersLink(): void {
$text = 'Visit example.com now';
$facets = [[
'index' => ['byteStart' => 6, 'byteEnd' => 17],
'features' => [['$type' => 'app.bsky.richtext.facet#link', 'uri' => 'https://example.com']],
]];
$html = $this->call('applyFacets', $text, $facets);
$this->assertStringContainsString('<a href="https://example.com"', $html);
$this->assertStringContainsString('example.com', $html);
}
public function testApplyFacetsRendersMention(): void {
$text = 'Hello @user.bsky.social today';
$facets = [[
'index' => ['byteStart' => 6, 'byteEnd' => 23],
'features' => [['$type' => 'app.bsky.richtext.facet#mention', 'did' => 'did:plc:fakeid']],
]];
$html = $this->call('applyFacets', $text, $facets);
$this->assertStringContainsString('href="https://bsky.app/profile/did:plc:fakeid"', $html);
$this->assertStringContainsString('@user.bsky.social', $html);
}
public function testApplyFacetsRendersHashtag(): void {
$text = 'Great game #GoldRace tonight';
$facets = [[
'index' => ['byteStart' => 11, 'byteEnd' => 21],
'features' => [['$type' => 'app.bsky.richtext.facet#tag', 'tag' => 'GoldRace']],
]];
$html = $this->call('applyFacets', $text, $facets);
$this->assertStringContainsString('href="https://bsky.app/hashtag/GoldRace"', $html);
$this->assertStringContainsString('#GoldRace', $html);
}
// -------------------------------------------------------------------------
// needsRefetch — staleness rules
// -------------------------------------------------------------------------
public function testNeedsRefetchWhenNeverCached(): void {
$this->assertTrue($this->call('needsRefetch', time() - 60, null),
'Always refetch when there is no cache entry');
}
public function testNoRefetchWhenPostIsFrozen(): void {
$publishedAt = time() - (8 * 86400); // 8 days ago
$fetchedAt = time() - 3600; // fetched 1 hour ago
$this->assertFalse($this->call('needsRefetch', $publishedAt, $fetchedAt),
'Posts older than 7 days are frozen and must not be re-fetched');
}
public function testRefetchRecentPostWithStaleCache(): void {
$publishedAt = time() - 1800; // 30 min old → window is 10 min
$fetchedAt = time() - 900; // cached 15 min ago → stale
$this->assertTrue($this->call('needsRefetch', $publishedAt, $fetchedAt),
'Cache older than the refresh window must trigger a re-fetch');
}
public function testNoRefetchRecentPostWithFreshCache(): void {
$publishedAt = time() - 1800; // 30 min old → window is 10 min
$fetchedAt = time() - 300; // cached 5 min ago → fresh
$this->assertFalse($this->call('needsRefetch', $publishedAt, $fetchedAt),
'Cache newer than the refresh window must NOT trigger a re-fetch');
}
public function testRefetchWindows(): void {
$cases = [
// [post age seconds, cache age seconds, expect refetch]
[1800, 601, true], // < 1 h post, > 10 min cache → stale
[1800, 599, false], // < 1 h post, < 10 min cache → fresh
[7200, 3601, true], // < 24 h post, > 1 h cache → stale
[7200, 3599, false], // < 24 h post, < 1 h cache → fresh
[3 * 86400, 43201, true], // < 7 d post, > 12 h cache → stale
[3 * 86400, 43199, false], // < 7 d post, < 12 h cache → fresh
];
foreach ($cases as [$postAge, $cacheAge, $expectRefetch]) {
$publishedAt = time() - $postAge;
$fetchedAt = time() - $cacheAge;
$this->assertSame(
$expectRefetch,
$this->call('needsRefetch', $publishedAt, $fetchedAt),
"postAge={$postAge}s cacheAge={$cacheAge}s"
);
}
}
// -------------------------------------------------------------------------
// handleConfigureAction — user config
// -------------------------------------------------------------------------
public function testHandleConfigureActionSavesDepth(): void {
Minz_Request::simulatePost(['depth' => '25']);
$this->ext->handleConfigureAction();
$this->assertSame(25, $this->ext->getUserConfigurationValue('depth'));
}
public function testHandleConfigureActionClampsMinDepth(): void {
// 0 is falsy after (int) cast, so the code falls back to the default of 10.
// A negative value exercises the max(1, ...) clamp.
Minz_Request::simulatePost(['depth' => '-5']);
$this->ext->handleConfigureAction();
$this->assertSame(1, $this->ext->getUserConfigurationValue('depth'));
}
public function testHandleConfigureActionClampsMaxDepth(): void {
Minz_Request::simulatePost(['depth' => '9999']);
$this->ext->handleConfigureAction();
$this->assertSame(1000, $this->ext->getUserConfigurationValue('depth'));
}
// -------------------------------------------------------------------------
// API transport — must not depend on PHP stream wrappers
// -------------------------------------------------------------------------
/**
* Regression test for the FreshRSS 1.30.0 breakage.
*
* 1.30.0 unregisters every stream wrapper except file:// and php:// at
* boot as an SSRF mitigation, which silently killed the previous
* file_get_contents()-based fetch. We reproduce that exact condition and
* assert apiGet() still reaches the API.
*/
public function testApiGetWorksWithoutHttpsStreamWrapper(): void {
if (!in_array('https', stream_get_wrappers(), true)) {
$this->markTestSkipped('No https stream wrapper registered in this PHP build.');
}
stream_wrapper_unregister('https');
try {
$data = $this->call('apiGet', 'com.atproto.identity.resolveHandle', ['handle' => 'hockeyviz.com']);
} finally {
stream_wrapper_restore('https');
}
if ($data === null) {
$this->markTestSkipped('Bluesky API unreachable: ' . implode('; ', Minz_Log::messages()));
}
$this->assertSame('did:plc:d4324t32vfi5xzydqbh2qdj3', $data['did'] ?? null);
}
/**
* A failed API call must return null *and* leave a trace in the log.
* The original code suppressed errors with @, so this whole failure mode
* was invisible for a day. Passes online (bogus method → HTTP 4xx) and
* offline (connection error) alike.
*/
public function testApiGetLogsWarningOnFailure(): void {
$data = $this->call('apiGet', 'com.example.doesNotExist', []);
$this->assertNull($data);
$this->assertNotEmpty(Minz_Log::messages(), 'A failed API call must be logged.');
$this->assertStringContainsString('[BlueskyThreads]', Minz_Log::messages()[0]);
}
}