fix(linkedin): enforce carousel cap and fail loudly on processing timeout

- publishCarousel now caps images at the platform max so the API/MCP paths
  can't bypass the 10-image limit the UI enforces
- waitForProcessing throws on timeout instead of posting against an asset
  still being processed; poll attempts/interval extracted to overridable
  protected methods for testability
- scope EnsureHasWorkspace to connect entry points + disconnect only;
  session-driven OAuth callbacks/selects stay ungated so a missing current
  workspace can't HTML-redirect the popup instead of closing it cleanly
This commit is contained in:
Paulo Castellano 2026-06-25 15:34:03 -03:00
parent 12a1baa757
commit 49c77406a0
4 changed files with 173 additions and 33 deletions

View file

@ -134,11 +134,11 @@ private function publishCarousel(?string $content, $media): array
{
$images = [];
foreach ($media as $item) {
if (! $item->isImage()) {
continue;
}
$imageItems = collect($media)
->filter(fn ($item) => $item->isImage())
->take($this->platform()->maxImages());
foreach ($imageItems as $item) {
$imageUrn = $this->uploadImage($item);
if ($imageUrn) {
@ -447,18 +447,20 @@ private function uploadDocument($mediaItem): ?string
/**
* Poll an asset until it finishes processing. LinkedIn rejects a post that
* references an asset still being processed, so we wait for AVAILABLE.
* references an asset still being processed, so we wait for AVAILABLE and
* fail loudly if it never gets there publishing against an unprocessed
* asset would error out at the API anyway, with a far less obvious message.
*/
private function waitForProcessing(string $resource, string $assetUrn, string $label, int $maxAttempts = 30): void
private function waitForProcessing(string $resource, string $assetUrn, string $label): void
{
$encodedUrn = urlencode($assetUrn);
for ($i = 0; $i < $maxAttempts; $i++) {
for ($i = 0; $i < $this->processingMaxAttempts(); $i++) {
$response = $this->getHttpClient()->get("{$this->baseUrl()}/rest/{$resource}/{$encodedUrn}");
if ($response->failed()) {
Log::warning("{$this->label()} {$label} status check failed", ['attempt' => $i]);
sleep(5);
sleep($this->processingPollSeconds());
continue;
}
@ -473,10 +475,26 @@ private function waitForProcessing(string $resource, string $assetUrn, string $l
throw new \Exception("{$this->label()} {$label} processing failed");
}
sleep(5);
sleep($this->processingPollSeconds());
}
Log::warning("{$this->label()} {$label} processing timeout, proceeding anyway");
throw new \Exception("{$this->label()} {$label} processing did not complete in time");
}
/**
* How many times to poll an uploaded asset for AVAILABLE before giving up.
*/
protected function processingMaxAttempts(): int
{
return 30;
}
/**
* Seconds to wait between asset processing status checks.
*/
protected function processingPollSeconds(): int
{
return 5;
}
private function downloadToTempFile(string $url, string $tempFile): void

View file

@ -77,60 +77,66 @@
});
// Social Connect routes
Route::middleware(['auth', EnsureHasWorkspace::class])->group(function () {
Route::get('connect/linkedin', [LinkedInController::class, 'connect'])->name('app.social.linkedin.connect');
Route::middleware(['auth'])->group(function () {
// Starting a connection reads the user's current workspace, so these require
// one — during onboarding they redirect to workspace creation. Disconnecting
// lives here too (and not behind EnsureAccountReady) so it works before a
// subscription exists; the controller still authorizes workspace ownership.
Route::middleware(EnsureHasWorkspace::class)->group(function () {
Route::get('connect/linkedin', [LinkedInController::class, 'connect'])->name('app.social.linkedin.connect');
Route::get('connect/x', [XController::class, 'connect'])->name('app.social.x.connect');
Route::get('connect/tiktok', [TikTokController::class, 'connect'])->name('app.social.tiktok.connect');
Route::get('connect/youtube', [YouTubeController::class, 'connect'])->name('app.social.youtube.connect');
Route::get('connect/facebook', [FacebookController::class, 'connect'])->name('app.social.facebook.connect');
Route::get('connect/instagram', [InstagramController::class, 'connect'])->name('app.social.instagram.connect');
Route::get('connect/instagram-facebook', [InstagramFacebookController::class, 'connect'])->name('app.social.instagram-facebook.connect');
Route::get('connect/threads', [ThreadsController::class, 'connect'])->name('app.social.threads.connect');
Route::get('connect/pinterest', [PinterestController::class, 'connect'])->name('app.social.pinterest.connect');
Route::get('connect/bluesky', [BlueskyController::class, 'connect'])->name('app.social.bluesky.connect');
Route::post('connect/bluesky', [BlueskyController::class, 'store'])->name('app.social.bluesky.store');
Route::get('connect/mastodon', [MastodonController::class, 'connect'])->name('app.social.mastodon.connect');
Route::post('connect/mastodon', [MastodonController::class, 'authorizeInstance'])->name('app.social.mastodon.authorize');
Route::post('connect/telegram', [TelegramController::class, 'connect'])->name('app.social.telegram.connect');
Route::get('connect/discord', [DiscordController::class, 'connect'])->name('app.social.discord.connect');
Route::delete('accounts/{account}', [SocialController::class, 'disconnect'])->name('app.accounts.disconnect');
});
// OAuth callbacks and identity selection resolve their workspace from the
// session set when the flow started, then self-close the popup. They run
// without the current-workspace gate so a momentarily missing current
// workspace can't HTML-redirect the popup instead of closing it cleanly.
Route::get('accounts/linkedin/callback', [LinkedInController::class, 'callback'])->name('app.social.linkedin.callback');
Route::get('accounts/linkedin/select', [LinkedInController::class, 'selectIdentity'])->name('app.social.linkedin.select-identity');
Route::post('accounts/linkedin/select', [LinkedInController::class, 'select'])->name('app.social.linkedin.select');
Route::get('connect/x', [XController::class, 'connect'])->name('app.social.x.connect');
Route::get('accounts/x/callback', [XController::class, 'callback'])->name('app.social.x.callback');
Route::get('connect/tiktok', [TikTokController::class, 'connect'])->name('app.social.tiktok.connect');
Route::get('accounts/tiktok/callback', [TikTokController::class, 'callback'])->name('app.social.tiktok.callback');
Route::get('connect/youtube', [YouTubeController::class, 'connect'])->name('app.social.youtube.connect');
Route::get('accounts/youtube/callback', [YouTubeController::class, 'callback'])->name('app.social.youtube.callback');
Route::get('accounts/youtube/select', [YouTubeController::class, 'selectChannel'])->name('app.social.youtube.select-channel');
Route::post('accounts/youtube/select', [YouTubeController::class, 'select'])->name('app.social.youtube.select');
Route::get('connect/facebook', [FacebookController::class, 'connect'])->name('app.social.facebook.connect');
Route::get('accounts/facebook/callback', [FacebookController::class, 'callback'])->name('app.social.facebook.callback');
Route::get('accounts/facebook/select', [FacebookController::class, 'selectPage'])->name('app.social.facebook.select-page');
Route::post('accounts/facebook/select', [FacebookController::class, 'select'])->name('app.social.facebook.select');
Route::get('connect/instagram', [InstagramController::class, 'connect'])->name('app.social.instagram.connect');
Route::get('accounts/instagram/callback', [InstagramController::class, 'callback'])->name('app.social.instagram.callback');
Route::get('accounts/instagram/select', [InstagramController::class, 'selectAccount'])->name('app.social.instagram.select-account');
Route::post('accounts/instagram/select', [InstagramController::class, 'select'])->name('app.social.instagram.select');
Route::get('connect/instagram-facebook', [InstagramFacebookController::class, 'connect'])->name('app.social.instagram-facebook.connect');
Route::get('accounts/instagram-facebook/callback', [InstagramFacebookController::class, 'callback'])->name('app.social.instagram-facebook.callback');
Route::get('accounts/instagram-facebook/select-page', [InstagramFacebookController::class, 'selectPage'])->name('app.social.instagram-facebook.select-page');
Route::post('accounts/instagram-facebook/select', [InstagramFacebookController::class, 'select'])->name('app.social.instagram-facebook.select');
Route::get('connect/threads', [ThreadsController::class, 'connect'])->name('app.social.threads.connect');
Route::get('accounts/threads/callback', [ThreadsController::class, 'callback'])->name('app.social.threads.callback');
Route::get('connect/pinterest', [PinterestController::class, 'connect'])->name('app.social.pinterest.connect');
Route::get('accounts/pinterest/callback', [PinterestController::class, 'callback'])->name('app.social.pinterest.callback');
Route::get('connect/bluesky', [BlueskyController::class, 'connect'])->name('app.social.bluesky.connect');
Route::post('connect/bluesky', [BlueskyController::class, 'store'])->name('app.social.bluesky.store');
Route::get('connect/mastodon', [MastodonController::class, 'connect'])->name('app.social.mastodon.connect');
Route::post('connect/mastodon', [MastodonController::class, 'authorizeInstance'])->name('app.social.mastodon.authorize');
Route::get('accounts/mastodon/callback', [MastodonController::class, 'callback'])->name('app.social.mastodon.callback');
Route::post('connect/telegram', [TelegramController::class, 'connect'])->name('app.social.telegram.connect');
Route::get('connect/discord', [DiscordController::class, 'connect'])->name('app.social.discord.connect');
Route::get('accounts/discord/callback', [DiscordController::class, 'callback'])->name('app.social.discord.callback');
// Disconnecting must also work during onboarding (before a subscription
// exists), so it lives here rather than behind EnsureAccountReady — the
// controller still authorizes workspace ownership.
Route::delete('accounts/{account}', [SocialController::class, 'disconnect'])->name('app.accounts.disconnect');
});
// Routes that require active subscription and completed onboarding

View file

@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Models\User;
use Inertia\Testing\AssertableInertia;
test('connect routes redirect to workspace creation when there is no current workspace', function () {
$user = User::factory()->create(['current_workspace_id' => null]);
@ -21,3 +22,15 @@
->get(route('app.social.x.connect'))
->assertRedirect(route('app.workspaces.create'));
});
test('oauth callbacks are not blocked by the workspace gate and self-close the popup', function () {
$user = User::factory()->create(['current_workspace_id' => null]);
$this->actingAs($user)
->get(route('app.social.x.callback'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('accounts/PopupCallback')
->where('success', false),
);
});

View file

@ -314,6 +314,60 @@
});
});
test('linkedin publisher caps a carousel at the platform max images', function () {
$media = [];
for ($i = 1; $i <= 12; $i++) {
$media[] = [
'id' => "test-media-{$i}",
'path' => "media/2026-01/carousel-{$i}.jpg",
'url' => "https://example.com/media/2026-01/carousel-{$i}.jpg",
'mime_type' => 'image/jpeg',
'original_filename' => "carousel-{$i}.jpg",
];
}
$this->post->update(['media' => $media]);
$initCallCount = 0;
Http::fake(function ($request) use (&$initCallCount) {
$url = $request->url();
if (str_contains($url, '/rest/images')) {
$initCallCount++;
return Http::response([
'value' => [
'uploadUrl' => "https://www.linkedin.com/dms/upload/v2/pic/carousel/{$initCallCount}",
'image' => "urn:li:image:CarouselImageUrn{$initCallCount}",
],
], 200);
}
if (str_contains($url, '/dms/upload/v2/pic/carousel/')) {
return Http::response(null, 201);
}
if (str_contains($url, '/rest/posts')) {
return Http::response(null, 201, ['x-restli-id' => 'urn:li:share:capped']);
}
return Http::response('fake-image-content', 200);
});
$this->publisher->publish($this->postPlatform);
// Only the first 10 images (LinkedIn::maxImages) are uploaded; the extra 2 are dropped.
expect($initCallCount)->toBe(10);
Http::assertSent(function ($request) {
if (! str_contains($request->url(), '/rest/posts')) {
return false;
}
return count(data_get($request->data(), 'content.multiImage.images', [])) === 10;
});
});
test('linkedin publisher can publish post with video', function () {
$this->post->update([
'media' => [
@ -575,6 +629,55 @@
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/rest/posts'));
});
test('linkedin publisher throws and does not post when document processing never completes', function () {
$this->postPlatform->update(['content_type' => ContentType::LinkedInPost]);
$this->post->update([
'media' => [[
'id' => 'doc-media-1', 'path' => 'media/2026-01/deck.pdf',
'url' => 'https://example.com/media/2026-01/deck.pdf',
'mime_type' => 'application/pdf', 'original_filename' => 'deck.pdf',
]],
]);
$uploadUrl = 'https://www.linkedin.com/dms-uploads/document/timeout';
Http::fake(function ($request) use ($uploadUrl) {
$url = $request->url();
if (str_contains($url, '/rest/documents') && str_contains($url, 'initializeUpload')) {
return Http::response(['value' => ['uploadUrl' => $uploadUrl, 'document' => 'urn:li:document:Timeout']], 200);
}
if ($url === $uploadUrl) {
return Http::response(null, 201);
}
if (str_contains($url, '/rest/documents/')) {
return Http::response(['status' => 'PROCESSING'], 200);
}
return Http::response('fake-pdf-bytes', 200);
});
$publisher = new class extends LinkedInPublisher
{
protected function processingMaxAttempts(): int
{
return 2;
}
protected function processingPollSeconds(): int
{
return 0;
}
};
expect(fn () => $publisher->publish($this->postPlatform))
->toThrow(Exception::class, 'processing did not complete in time');
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/rest/posts'));
});
test('linkedin publisher throws and does not post when document init fails', function () {
$this->postPlatform->update(['content_type' => ContentType::LinkedInPost]);
$this->post->update([