fix(publish): never surface raw internal errors in the failure email
The post-failure email renders each platform's stored error_message verbatim. On an unexpected publish error the job's catch-all (and the job-failed hook) stored the raw exception message — for a PHP TypeError that includes the server file path (.../releases/<id>/app/Services/Social/XPublisher.php on line 130), which then reached the customer's inbox. Only our own SocialPublishException carries a vetted, user-facing message. Every other throwable (engine errors like TypeError, or library exceptions such as Intervention's decoder errors which embed temp paths) is now replaced with a generic line; the raw detail stays in the logs. Token-expired and rescheduled paths are unchanged. Note: publishers that still throw plain \Exception for user-facing reasons (some X/Facebook/Threads/LinkedIn cases) will now show the generic message for those; converting them to typed SocialPublishExceptions to restore specific copy is a worthwhile follow-up.
This commit is contained in:
parent
4417aa99ac
commit
08e64ae074
2 changed files with 62 additions and 6 deletions
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
Loading…
Reference in a new issue