trypost/tests/Feature/Api/PostMediaApiTest.php
Paulo Castellano 4892ee75a5 refactor: move URL validation to the request layer with active_url
The MediaAttacher used to roll its own SSRF guard with DNS resolution
and a static fakeUrlSafety() flag for tests. Validating URLs is a
request-layer concern, not a service-layer one. Laravel ships
'active_url' which does the same DNS resolvability check via
dns_get_record — applying it at the FormRequest / MCP validate() level
catches dead URLs upfront with a proper 422 instead of letting the
download silently fail.

- Replace the inline 'urls.*' => ['url:http,https'] rule with
  ['url:http,https', 'active_url'] in both Api/PostController::attachMedia
  and Mcp/Tools/Post/AttachMediaFromUrlTool.
- Drop isUrlSafe(), fakeUrlSafety(), resetUrlSafety(), $skipUrlSafety
  from MediaAttacher. The remaining defenses (Http::sink streaming +
  progress abort at MAX_BYTES, allow_redirects: false, MIME allowlist)
  cover the operational concerns.
- Restore tests/TestCase to the original setUp — no SSRF bypass needed
  anymore because active_url is satisfied by the test hosts.
- Swap synthetic test hosts (cdn.example.com / evil.example.com) for
  example.com / example.org. Both are RFC-reserved AND have stable A
  records, so active_url accepts them while Http::fake() still
  intercepts the actual request.

For SSRF defense beyond 'active_url' (which doesn't block private IPs),
trypost relies on production network egress controls. Open-source
self-hosters who run without a firewall accept the corresponding risk;
that's a deployment concern, not a request validation concern.
2026-05-04 14:35:28 -03:00

149 lines
4.9 KiB
PHP

<?php
declare(strict_types=1);
use App\Enums\SocialAccount\Platform;
use App\Models\Media;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\Workspace;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
beforeEach(function () {
$result = createApiTestToken();
$this->user = $result['user'];
$this->workspace = $result['workspace'];
$this->plainToken = $result['plain_token'];
$this->socialAccount = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::LinkedIn,
]);
$this->post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
PostPlatform::factory()->linkedin()->create([
'post_id' => $this->post->id,
'social_account_id' => $this->socialAccount->id,
'enabled' => true,
]);
Storage::fake();
});
it('attaches media from url', function () {
Http::fake([
'example.com/photo.png' => Http::response(
file_get_contents(__DIR__.'/../../fixtures/1x1.png'),
200,
['Content-Type' => 'image/png'],
),
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.attach-media', $this->post), [
'urls' => ['https://example.com/photo.png'],
])
->assertOk()
->assertJsonPath('attached_count', 1)
->assertJsonPath('failed_urls', []);
expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(1);
expect($this->post->fresh()->media)->toHaveCount(1);
});
it('reports failures for unreachable urls', function () {
Http::fake([
'example.com/missing.png' => Http::response(null, 404),
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.attach-media', $this->post), [
'urls' => ['https://example.com/missing.png'],
])
->assertOk()
->assertJsonPath('attached_count', 0)
->assertJsonPath('failed_urls.0', 'https://example.com/missing.png');
});
it('cannot attach media to a post from another workspace', function () {
$other = Workspace::factory()->create();
$post = Post::factory()->create(['workspace_id' => $other->id, 'user_id' => $this->user->id]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.attach-media', $post), [
'urls' => ['https://example.com/photo.png'],
])
->assertNotFound();
});
it('previews per platform with sanitized content and length', function () {
$this->post->update(['content' => str_repeat('a', 500)]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->getJson(route('api.posts.preview', $this->post))
->assertOk()
->assertJsonStructure([
'post_id',
'original_content',
'original_length',
'platforms' => [
'*' => [
'post_platform_id',
'platform',
'content_type',
'sanitized_content',
'sanitized_length',
'max_content_length',
'truncated',
],
],
])
->assertJsonPath('original_length', 500);
});
it('returns metrics shape including unsupported reason for unpublished platforms', function () {
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->getJson(route('api.posts.metrics', $this->post))
->assertOk()
->assertJsonStructure([
'post_id',
'platforms' => [
'*' => [
'post_platform_id',
'platform',
'status',
'platform_post_id',
'platform_url',
'metrics',
],
],
])
->assertJsonPath('platforms.0.metrics.unsupported', true)
->assertJsonPath('platforms.0.metrics.reason', 'not_published');
});
it('cannot get metrics from another workspace post', function () {
$other = Workspace::factory()->create();
$post = Post::factory()->create(['workspace_id' => $other->id, 'user_id' => $this->user->id]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->getJson(route('api.posts.metrics', $post))
->assertNotFound();
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->getJson(route('api.posts.preview', $post))
->assertNotFound();
});
it('rejects attach media payload without urls', function () {
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.attach-media', $this->post), [])
->assertUnprocessable()
->assertJsonValidationErrors(['urls']);
});