diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index 5bf96054..eca432f0 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -165,7 +165,7 @@ public function handle(): void 'platform' => $this->postPlatform->platform->value, 'error' => $e->getMessage(), ]); - $this->postPlatform->markAsFailed($e->getMessage(), [ + $this->postPlatform->markAsFailed($this->safeFailureMessage($e), [ 'category' => 'unknown', 'failed_at' => now()->toIso8601String(), 'content_length' => mb_strlen($this->postPlatform->post->content ?? ''), @@ -223,6 +223,20 @@ private function broadcastStatus(): void PostPlatformStatusUpdated::dispatch($this->postPlatform->fresh()); } + /** + * A failure message safe to surface to the user — it ends up in the + * post-failure email. Only our own publish exceptions carry a vetted + * user-facing message; any other throwable (engine errors like TypeError, + * or library exceptions) can embed file paths and internals, so it's + * replaced with a generic line. The raw detail stays in the logs. + */ + private function safeFailureMessage(\Throwable $e): string + { + return $e instanceof SocialPublishException + ? $e->userMessage + : 'An unexpected error occurred while publishing. Please try again.'; + } + private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher|YouTubePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher|PinterestPublisher|BlueskyPublisher|MastodonPublisher|TelegramPublisher|DiscordPublisher { return match ($this->postPlatform->platform) { @@ -308,10 +322,13 @@ public function failed(?\Throwable $exception): void $this->postPlatform->refresh(); if ($this->postPlatform->status !== PostPlatformStatus::Published) { - $this->postPlatform->markAsFailed($exception?->getMessage() ?? 'Unknown error', [ - 'category' => 'job_failed', - 'failed_at' => now()->toIso8601String(), - ]); + $this->postPlatform->markAsFailed( + $exception ? $this->safeFailureMessage($exception) : 'Unknown error', + [ + 'category' => 'job_failed', + 'failed_at' => now()->toIso8601String(), + ] + ); $this->updatePostStatus(); $this->broadcastStatus(); } diff --git a/tests/Feature/Jobs/PublishToSocialPlatformTest.php b/tests/Feature/Jobs/PublishToSocialPlatformTest.php index cb3ff6d6..d3d955e7 100644 --- a/tests/Feature/Jobs/PublishToSocialPlatformTest.php +++ b/tests/Feature/Jobs/PublishToSocialPlatformTest.php @@ -181,7 +181,46 @@ $this->postPlatform->refresh(); expect($this->postPlatform->status)->toBe(PlatformStatus::Failed); - expect($this->postPlatform->error_message)->toBe('API Error'); + // Untrusted exceptions are genericized — only vetted publish exceptions surface their message. + expect($this->postPlatform->error_message)->toBe('An unexpected error occurred while publishing. Please try again.'); +}); + +test('publish keeps the vetted user message from a publish exception', function () { + Event::fake(); + + $publisher = Mockery::mock(LinkedInPublisher::class); + $publisher->shouldReceive('publish')->andThrow(new LinkedInPublishException( + userMessage: 'LinkedIn rejected this post.', + category: ErrorCategory::ContentPolicy, + )); + + $this->app->instance(LinkedInPublisher::class, $publisher); + + (new PublishToSocialPlatform($this->postPlatform))->handle(); + + $this->postPlatform->refresh(); + expect($this->postPlatform->status)->toBe(PlatformStatus::Failed); + expect($this->postPlatform->error_message)->toBe('LinkedIn rejected this post.'); +}); + +test('publish never leaks a raw internal error to the failure record (and the email)', function () { + Event::fake(); + + // A PHP TypeError embeds the server file path in its message; it must not reach the user. + $publisher = Mockery::mock(LinkedInPublisher::class); + $publisher->shouldReceive('publish')->andThrow(new TypeError( + 'X::getMediaCategory(): Argument #1 ($mimeType) must be of type string, null given, called in /home/forge/app.trypost.it/releases/72198060/app/Services/Social/XPublisher.php on line 130' + )); + + $this->app->instance(LinkedInPublisher::class, $publisher); + + (new PublishToSocialPlatform($this->postPlatform))->handle(); + + $this->postPlatform->refresh(); + expect($this->postPlatform->status)->toBe(PlatformStatus::Failed); + expect($this->postPlatform->error_message)->toBe('An unexpected error occurred while publishing. Please try again.') + ->and($this->postPlatform->error_message)->not->toContain('/home/forge') + ->and($this->postPlatform->error_message)->not->toContain('getMediaCategory'); }); test('publish to social platform marks account as token expired on auth failure', function () {