From a63e9ea7785cee99b3a8a88d5f45871466e16855 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 29 Jun 2026 13:41:47 -0300 Subject: [PATCH] fix(media): treat any download failure (timeout/DNS/refused) as a failed fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MediaAttacher::download() only caught RuntimeException, but a connection-level failure throws Illuminate\Http\Client\ConnectionException (extends Exception, not RuntimeException) — e.g. a slow/unreachable proxy hitting the 20s timeout. On the API hosting path that propagated as a 500 (not the promised 422), skipped the batch rollback (orphaning an already-hosted item), and leaked the temp file. Catch Throwable so any fetch failure returns null → the caller rejects cleanly with 422 and rolls back. Also hardens the existing MCP/REST attach-from-url paths (a timeout there now reports a failed URL instead of 500). --- app/Services/Post/MediaAttacher.php | 4 +++- tests/Feature/Api/PostMediaApiTest.php | 31 ++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/app/Services/Post/MediaAttacher.php b/app/Services/Post/MediaAttacher.php index d84ec4ca..3319eb63 100644 --- a/app/Services/Post/MediaAttacher.php +++ b/app/Services/Post/MediaAttacher.php @@ -10,6 +10,7 @@ use App\Models\Workspace; use Illuminate\Support\Facades\Http; use RuntimeException; +use Throwable; /** * Downloads public URLs and attaches them as media to a post — used by @@ -153,7 +154,8 @@ private function download(string $url): ?array }, ]) ->get($url); - } catch (RuntimeException) { + } catch (Throwable) { + // Any fetch failure (timeout, DNS, refused, oversize) is a failed download. @unlink($temp); return null; diff --git a/tests/Feature/Api/PostMediaApiTest.php b/tests/Feature/Api/PostMediaApiTest.php index c4008e32..daa7c3eb 100644 --- a/tests/Feature/Api/PostMediaApiTest.php +++ b/tests/Feature/Api/PostMediaApiTest.php @@ -8,6 +8,7 @@ use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Models\Workspace; +use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Storage; @@ -299,6 +300,36 @@ expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(0); }); +it('rejects and rolls back when a media url connection fails (timeout/dns)', function () { + $this->socialAccount->update(['is_active' => true]); + + Http::fake([ + 'cdn.example.com/good.jpg' => Http::response( + file_get_contents(__DIR__.'/../../fixtures/1x1.png'), + 200, + ['Content-Type' => 'image/png'], + ), + 'cdn.example.com/timeout.jpg' => fn () => throw new ConnectionException('Connection timed out'), + ]); + + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->postJson(route('api.posts.store'), [ + 'content' => 'Timeout media post', + 'media' => [ + ['url' => 'https://cdn.example.com/good.jpg'], + ['url' => 'https://cdn.example.com/timeout.jpg'], + ], + 'platforms' => [ + ['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'], + ], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['media']); + + expect(Post::where('content', 'Timeout media post')->exists())->toBeFalse(); + expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(0); +}); + it('rejects creating a post when an external media url is not a supported type', function () { $this->socialAccount->update(['is_active' => true]);