|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
use App\Jobs\DeletePipeline\FanoutDeletePipeline;
|
|
|
|
|
use App\Jobs\StatusPipeline\StatusActivityPubDeliver;
|
fix: resolve larastan class.notFound errors
- Add missing FractalService import to Groups/GroupCommentService and
Groups/GroupPostService (wrong namespace resolution)
- Update Inbox handler traits to use App\Models\* namespace instead of
old App\* references (Status, Profile, DirectMessage, Media, Follower,
Like, Instance, Story, User, FollowRequest, Notification, UserFilter,
StoryView)
- Update HttpClientMigrationTest to use App\Models\* namespace
4 weeks ago
|
|
|
use App\Models\Follower;
|
|
|
|
|
use App\Models\Profile;
|
|
|
|
|
use App\Models\Status;
|
|
|
|
|
use App\Models\User;
|
|
|
|
|
use App\Services\ActivityPubFetchService;
|
|
|
|
|
use App\Services\MediaStorageService;
|
|
|
|
|
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
|
|
|
|
use Illuminate\Http\Client\ConnectionException;
|
|
|
|
|
use Illuminate\Support\Facades\Cache;
|
|
|
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
|
|
|
|
|
|
uses(LazilyRefreshDatabase::class);
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
|--------------------------------------------------------------------------
|
|
|
|
|
| HTTP Client Migration Tests
|
|
|
|
|
|--------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
| Verifies that the Guzzle-to-Laravel HTTP client migration works
|
|
|
|
|
| correctly for MediaStorageService, ActivityPub delivery jobs, and
|
|
|
|
|
| ActivityPubFetchService URI resolution.
|
|
|
|
|
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* ActivityPub delivery only performs HTTP requests in the production
|
|
|
|
|
* environment; outside production the service logs and returns. Run the job
|
|
|
|
|
* in a forged production environment so delivery is actually attempted, and
|
|
|
|
|
* restore the environment afterwards.
|
|
|
|
|
*/
|
|
|
|
|
function runJobInProduction(callable $fn): void
|
|
|
|
|
{
|
|
|
|
|
$app = app();
|
|
|
|
|
$previous = $app['env'];
|
|
|
|
|
$app['env'] = 'production';
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$fn();
|
|
|
|
|
} finally {
|
|
|
|
|
$app['env'] = $previous;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* In production, delivery validates each destination host (public-IP DNS
|
|
|
|
|
* resolution + banned-domain check). Seed both caches so validation passes
|
|
|
|
|
* without a real DNS lookup or DB query. Seed after any factory creation, since
|
|
|
|
|
* the lazy DB refresh can flush the cache store.
|
|
|
|
|
*
|
|
|
|
|
* @param array<int, string> $hosts
|
|
|
|
|
*/
|
|
|
|
|
function seedMigrationDeliveryHosts(array $hosts): void
|
|
|
|
|
{
|
|
|
|
|
foreach ($hosts as $host) {
|
|
|
|
|
Cache::put('helpers:url:public-ips:'.hash('xxh128', $host), ['203.0.113.40'], 3600);
|
|
|
|
|
}
|
|
|
|
|
Cache::put('instances:banned:domains', [], 1209600);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('MediaStorageService::head()', function () {
|
|
|
|
|
it('returns length and mime on successful HEAD response', function () {
|
|
|
|
|
Http::fake([
|
|
|
|
|
'https://example.com/image.jpg' => Http::response('', 200, [
|
|
|
|
|
'Content-Length' => '50000',
|
|
|
|
|
'Content-Type' => 'image/jpeg',
|
|
|
|
|
]),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = MediaStorageService::head('https://example.com/image.jpg');
|
|
|
|
|
|
|
|
|
|
expect($result)->toBeArray();
|
|
|
|
|
expect($result['length'])->toBe(50000);
|
|
|
|
|
expect($result['mime'])->toBe('image/jpeg');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns false when content-length is too small', function () {
|
|
|
|
|
Http::fake([
|
|
|
|
|
'https://example.com/tiny.jpg' => Http::response('', 200, [
|
|
|
|
|
'Content-Length' => '5',
|
|
|
|
|
'Content-Type' => 'image/jpeg',
|
|
|
|
|
]),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = MediaStorageService::head('https://example.com/tiny.jpg');
|
|
|
|
|
|
|
|
|
|
expect($result)->toBeFalse();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns false when response is not successful', function () {
|
|
|
|
|
Http::fake([
|
|
|
|
|
'https://example.com/missing.jpg' => Http::response('', 404),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = MediaStorageService::head('https://example.com/missing.jpg');
|
|
|
|
|
|
|
|
|
|
expect($result)->toBeFalse();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns false when content-length header is missing', function () {
|
|
|
|
|
Http::fake([
|
|
|
|
|
'https://example.com/noheaders.jpg' => Http::response('', 200, [
|
|
|
|
|
'Content-Type' => 'image/jpeg',
|
|
|
|
|
]),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = MediaStorageService::head('https://example.com/noheaders.jpg');
|
|
|
|
|
|
|
|
|
|
expect($result)->toBeFalse();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns false on connection exception', function () {
|
|
|
|
|
Http::fake([
|
|
|
|
|
'https://unreachable.example.com/*' => fn () => throw new ConnectionException('Connection refused'),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = MediaStorageService::head('https://unreachable.example.com/image.jpg');
|
|
|
|
|
|
|
|
|
|
expect($result)->toBeFalse();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('FanoutDeletePipeline delivery', function () {
|
|
|
|
|
it('sends delete activities to known shared inboxes via Http::pool', function () {
|
|
|
|
|
Http::fake();
|
|
|
|
|
|
|
|
|
|
$user = User::factory()->create();
|
|
|
|
|
$user->refresh();
|
|
|
|
|
$profile = $user->profile;
|
|
|
|
|
|
|
|
|
|
// Create remote profiles with shared inboxes
|
|
|
|
|
Profile::factory()->remote()->create([
|
|
|
|
|
'sharedInbox' => 'https://remote1.example/inbox',
|
|
|
|
|
]);
|
|
|
|
|
Profile::factory()->remote()->create([
|
|
|
|
|
'sharedInbox' => 'https://remote2.example/inbox',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Cache::forget('pf:ap:known_instances');
|
|
|
|
|
|
|
|
|
|
seedMigrationDeliveryHosts(['remote1.example', 'remote2.example']);
|
|
|
|
|
|
|
|
|
|
$job = new FanoutDeletePipeline($profile);
|
|
|
|
|
runJobInProduction(fn () => $job->handle());
|
|
|
|
|
|
|
|
|
|
Http::assertSentCount(2);
|
|
|
|
|
Http::assertSent(
|
|
|
|
|
fn ($request) => $request->url() === 'https://remote1.example/inbox'
|
|
|
|
|
&& $request->method() === 'POST'
|
|
|
|
|
&& str_contains($request->header('Content-Type')[0] ?? '', 'application/ld+json')
|
|
|
|
|
);
|
|
|
|
|
Http::assertSent(
|
|
|
|
|
fn ($request) => $request->url() === 'https://remote2.example/inbox'
|
|
|
|
|
&& $request->method() === 'POST'
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('skips delivery when profile lacks private key', function () {
|
|
|
|
|
Http::fake();
|
|
|
|
|
|
|
|
|
|
$profile = Profile::factory()->remote()->create([
|
|
|
|
|
'private_key' => null,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$job = new FanoutDeletePipeline($profile);
|
|
|
|
|
$job->handle();
|
|
|
|
|
|
|
|
|
|
Http::assertNothingSent();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('StatusActivityPubDeliver delivery', function () {
|
|
|
|
|
it('sends create activities to audience inboxes via Http::pool', function () {
|
|
|
|
|
Http::fake();
|
|
|
|
|
|
|
|
|
|
$user = User::factory()->create();
|
|
|
|
|
$user->refresh();
|
|
|
|
|
$profile = $user->profile;
|
|
|
|
|
|
|
|
|
|
// Create a remote follower with inbox
|
|
|
|
|
$remoteFollower = Profile::factory()->remote()->create([
|
|
|
|
|
'sharedInbox' => 'https://remote.example/inbox',
|
|
|
|
|
'inbox_url' => 'https://remote.example/users/bob/inbox',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// Make them a follower
|
|
|
|
|
Follower::create([
|
|
|
|
|
'profile_id' => $remoteFollower->id,
|
|
|
|
|
'following_id' => $profile->id,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// Clear audience cache
|
|
|
|
|
Cache::forget('pf:services:follower:audience:'.$profile->id);
|
|
|
|
|
|
|
|
|
|
$status = Status::factory()->create([
|
|
|
|
|
'profile_id' => $profile->id,
|
|
|
|
|
'type' => 'photo',
|
|
|
|
|
'scope' => 'public',
|
|
|
|
|
'visibility' => 'public',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// Force local and no url/uri to pass the guard clause
|
|
|
|
|
$status->local = true;
|
|
|
|
|
$status->setAttribute('url', null);
|
|
|
|
|
$status->uri = null;
|
|
|
|
|
$status->saveQuietly();
|
|
|
|
|
$status->refresh();
|
|
|
|
|
|
|
|
|
|
seedMigrationDeliveryHosts(['remote.example']);
|
|
|
|
|
|
|
|
|
|
$job = new StatusActivityPubDeliver($status);
|
|
|
|
|
runJobInProduction(fn () => $job->handle());
|
|
|
|
|
|
|
|
|
|
Http::assertSent(
|
|
|
|
|
fn ($request) => $request->url() === 'https://remote.example/inbox'
|
|
|
|
|
&& $request->method() === 'POST'
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('does not deliver non-local statuses', function () {
|
|
|
|
|
Http::fake();
|
|
|
|
|
|
|
|
|
|
$user = User::factory()->create();
|
|
|
|
|
$user->refresh();
|
|
|
|
|
|
|
|
|
|
$status = Status::factory()->create([
|
|
|
|
|
'profile_id' => $user->profile->id,
|
|
|
|
|
'uri' => 'https://remote.example/status/1',
|
|
|
|
|
'local' => false,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$job = new StatusActivityPubDeliver($status);
|
|
|
|
|
$job->handle();
|
|
|
|
|
|
|
|
|
|
Http::assertNothingSent();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('ActivityPubFetchService URI resolution', function () {
|
|
|
|
|
it('resolves a relative location against a base URL', function () {
|
|
|
|
|
$method = new ReflectionMethod(ActivityPubFetchService::class, 'resolveRedirect');
|
|
|
|
|
$method->setAccessible(true);
|
|
|
|
|
|
|
|
|
|
$result = $method->invoke(null, 'https://example.com/users/alice', '/statuses/123');
|
|
|
|
|
|
|
|
|
|
expect($result)->toBe('https://example.com/statuses/123');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('resolves an absolute location unchanged', function () {
|
|
|
|
|
$method = new ReflectionMethod(ActivityPubFetchService::class, 'resolveRedirect');
|
|
|
|
|
$method->setAccessible(true);
|
|
|
|
|
|
|
|
|
|
$result = $method->invoke(null, 'https://pixelfed.com/users/alice', 'https://pixelfed.social/status/456');
|
|
|
|
|
|
|
|
|
|
expect($result)->toBe('https://pixelfed.social/status/456');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns null for empty location', function () {
|
|
|
|
|
$method = new ReflectionMethod(ActivityPubFetchService::class, 'resolveRedirect');
|
|
|
|
|
$method->setAccessible(true);
|
|
|
|
|
|
|
|
|
|
$result = $method->invoke(null, 'https://example.com/users/alice', '');
|
|
|
|
|
|
|
|
|
|
expect($result)->toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns null for location with control characters', function () {
|
|
|
|
|
$method = new ReflectionMethod(ActivityPubFetchService::class, 'resolveRedirect');
|
|
|
|
|
$method->setAccessible(true);
|
|
|
|
|
|
|
|
|
|
$result = $method->invoke(null, 'https://example.com/users/alice', "/bad\x00path");
|
|
|
|
|
|
|
|
|
|
expect($result)->toBeNull();
|
|
|
|
|
});
|
|
|
|
|
});
|