diff --git a/tests/Feature/Federation/QuotePostsTest.php b/tests/Feature/Federation/QuotePostsTest.php index 46fb310ad..8a648a592 100644 --- a/tests/Feature/Federation/QuotePostsTest.php +++ b/tests/Feature/Federation/QuotePostsTest.php @@ -10,6 +10,7 @@ use App\Services\QuoteService; use App\Transformer\ActivityPub\Verb\CreateNote; use App\Transformer\ActivityPub\Verb\Note; use App\Util\ActivityPub\Inbox; +use App\Util\ActivityPub\Validator\QuoteRequestValidator; use Illuminate\Foundation\Testing\LazilyRefreshDatabase; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; @@ -734,3 +735,264 @@ describe('api', function () { expect($status->fresh()->quote_policy)->toBeNull(); }); }); + +/* +|-------------------------------------------------------------------------- +| Additional coverage (regression + new) +|-------------------------------------------------------------------------- +*/ + +describe('policy resolution units', function () { + it('forces nobody for a remote profile regardless of settings', function () { + $bob = quoteRemoteProfile(); + + expect(QuoteService::accountPolicy($bob))->toBe(QuoteService::POLICY_NOBODY); + }); + + it('falls back to everyone for an unknown can_quote value', function () { + $user = quoteLocalUser(); + $settings = $user->settings; + $settings->can_quote = 'garbage-value'; + $settings->save(); + QuoteService::forgetPolicy($user->profile->id); + + expect(QuoteService::accountPolicy($user->profile))->toBe(QuoteService::POLICY_EVERYONE); + }); + + it('caches the account policy until forgetPolicy clears it', function () { + $user = quoteLocalUser(); + $profileId = $user->profile->id; + + // Warm the cache at the default. + expect(QuoteService::accountPolicy($user->profile))->toBe(QuoteService::POLICY_EVERYONE); + + // Change the stored setting behind the cache's back. + $settings = $user->settings; + $settings->can_quote = 'followers'; + $settings->save(); + + // Still served from cache (re-read the profile so it is not the stale + // in-memory relation but the value still comes from the warmed cache). + expect(QuoteService::accountPolicy(Profile::find($profileId)))->toBe(QuoteService::POLICY_EVERYONE); + + // After invalidation the new value resolves from the fresh settings. + QuoteService::forgetPolicy($profileId); + expect(QuoteService::accountPolicy(Profile::find($profileId)))->toBe(QuoteService::POLICY_FOLLOWERS); + }); + + it('maps bitmasks to and from api policy strings', function () { + expect(QuoteService::toApiPolicy(QuoteService::FLAG_PUBLIC))->toBe('public') + ->and(QuoteService::toApiPolicy(QuoteService::FLAG_FOLLOWERS))->toBe('followers') + ->and(QuoteService::toApiPolicy(QuoteService::FLAGS_NOBODY))->toBe('nobody'); + }); + + it('splits a combined bitmask into automatic and manual bytes', function () { + $flags = QuoteService::FLAG_FOLLOWERS + | (QuoteService::FLAG_PUBLIC << QuoteService::MANUAL_SHIFT); + + expect(QuoteService::automatic($flags))->toBe(QuoteService::FLAG_FOLLOWERS) + ->and(QuoteService::manual($flags))->toBe(QuoteService::FLAG_PUBLIC); + }); +}); + +describe('isQuotable structural checks', function () { + it('is not quotable when the post is a reblog', function () { + $user = quoteLocalUser(); + $original = quoteLocalStatus($user->profile); + $reblog = quoteLocalStatus($user->profile, ['reblog_of_id' => $original->id]); + + expect(QuoteService::isQuotable($reblog))->toBeFalse(); + }); + + it('is not quotable for a private-scope post', function () { + $user = quoteLocalUser(); + $status = quoteLocalStatus($user->profile, ['scope' => 'private', 'visibility' => 'private']); + + expect(QuoteService::isQuotable($status))->toBeFalse(); + }); + + it('is not quotable when the author account is deleted/suspended', function () { + $user = quoteLocalUser(); + $status = quoteLocalStatus($user->profile); + + $profile = $user->profile; + $profile->status = 'delete'; + $profile->save(); + + expect(QuoteService::isQuotable($status->fresh()))->toBeFalse(); + }); +}); + +describe('canQuote guards', function () { + it('refuses a local actor', function () { + $author = quoteLocalUser(); + $status = quoteLocalStatus($author->profile); + $localActor = quoteLocalUser(); + + expect(QuoteService::canQuote($status, $localActor->profile))->toBeFalse(); + }); + + it('refuses a suspended remote actor', function () { + $author = quoteLocalUser(); + $status = quoteLocalStatus($author->profile); + $bob = quoteRemoteProfile(); + $bob->status = 'delete'; + $bob->save(); + + expect(QuoteService::canQuote($status, $bob->fresh()))->toBeFalse(); + }); + + it('allows a remote actor under an everyone policy', function () { + $author = quoteLocalUser(); + $status = quoteLocalStatus($author->profile); + $bob = quoteRemoteProfile(); + + expect(QuoteService::canQuote($status, $bob))->toBeTrue(); + }); +}); + +describe('QuoteRequest payload validation', function () { + it('accepts a well-formed QuoteRequest payload', function () { + $user = quoteLocalUser(); + $status = quoteLocalStatus($user->profile); + $bob = quoteRemoteProfile(); + + expect(QuoteRequestValidator::validate(quoteRequestPayload($bob, $status)))->toBeTrue(); + }); + + it('rejects payloads missing required fields or with the wrong type', function () { + $user = quoteLocalUser(); + $status = quoteLocalStatus($user->profile); + $bob = quoteRemoteProfile(); + + $base = quoteRequestPayload($bob, $status); + + foreach (['@context', 'actor', 'object', 'instrument'] as $field) { + $missing = $base; + unset($missing[$field]); + expect(QuoteRequestValidator::validate($missing))->toBeFalse("missing {$field} should fail"); + } + + expect(QuoteRequestValidator::validate(array_merge($base, ['type' => 'Create'])))->toBeFalse() + ->and(QuoteRequestValidator::validate(array_merge($base, ['id' => 'not-a-url'])))->toBeFalse() + ->and(QuoteRequestValidator::validate(array_merge($base, ['id' => 'https://x.example/'.str_repeat('a', 600)])))->toBeFalse(); + }); +}); + +describe('inbound collision + drops', function () { + it('rejects a second actor requesting a quote_url another actor already holds', function () { + $user = quoteLocalUser(); + $status = quoteLocalStatus($user->profile); + $bob = quoteRemoteProfile('remote.example', 'bob'); + $carol = quoteRemoteProfile('remote.example', 'carol'); + quoteSeedHosts(); + + // bob is approved for a given quote_url. + $quoteUrl = $bob->remote_url.'/statuses/1'; + QuoteService::authorize($status, $bob, $quoteUrl); + + // carol requests using the SAME quote_url (a collision) → Reject, no new stamp. + quoteDeliver($carol, quoteRequestPayload($carol, $status, [ + 'id' => $carol->remote_url.'/statuses/9/quote', + 'instrument' => $quoteUrl, + ])); + + expect(QuoteAuthorization::count())->toBe(1) + ->and(quoteSentActivities()[0]['type'])->toBe('Reject'); + }); + + it('drops an invalid QuoteRequest without answering', function () { + $user = quoteLocalUser(); + $status = quoteLocalStatus($user->profile); + $bob = quoteRemoteProfile(); + quoteSeedHosts(); + + // Missing instrument → validator fails → Inbox drops it silently. + $payload = quoteRequestPayload($bob, $status); + unset($payload['instrument']); + + quoteDeliver($bob, $payload); + + expect(QuoteAuthorization::count())->toBe(0) + ->and(quoteSentActivities())->toBeEmpty(); + }); +}); + +describe('revocation guards', function () { + it('revoke is idempotent: a second call does not re-dispatch a Delete', function () { + $user = quoteLocalUser(); + $status = quoteLocalStatus($user->profile); + $bob = quoteRemoteProfile(); + + $auth = QuoteService::authorize($status, $bob, $bob->remote_url.'/statuses/1'); + + QuoteService::revoke($auth); + QuoteService::revoke($auth->fresh()); + + Queue::assertPushed(RevokeQuoteAuthorizationPipeline::class, 1); + }); + + it('the pipeline sends nothing when the authorization is missing', function () { + quoteInProduction(fn () => (new RevokeQuoteAuthorizationPipeline(999999999))->handle()); + + expect(quoteSentActivities())->toBeEmpty(); + }); + + it('the pipeline sends nothing when the authorization is still approved', function () { + $user = quoteLocalUser(); + $status = quoteLocalStatus($user->profile); + $bob = quoteRemoteProfile(); + quoteSeedHosts(); + + $auth = QuoteService::authorize($status, $bob, $bob->remote_url.'/statuses/1'); + + // Not revoked → handle() must early-return without a Delete. + quoteInProduction(fn () => (new RevokeQuoteAuthorizationPipeline($auth->id))->handle()); + + expect(quoteSentActivities())->toBeEmpty(); + }); + + it('forgetQuote hard-deletes a stamp without federating anything', function () { + $user = quoteLocalUser(); + $status = quoteLocalStatus($user->profile); + $bob = quoteRemoteProfile(); + + $quoteUrl = $bob->remote_url.'/statuses/1'; + QuoteService::authorize($status, $bob, $quoteUrl); + + QuoteService::forgetQuote($bob->id, $quoteUrl); + + expect(QuoteAuthorization::count())->toBe(0); + Queue::assertNotPushed(RevokeQuoteAuthorizationPipeline::class); + expect(quoteSentActivities())->toBeEmpty(); + }); +}); + +describe('sameUrl normalization', function () { + it('ignores scheme/host case and a trailing slash', function () { + expect(QuoteService::sameUrl('https://Remote.Example/users/bob', 'https://remote.example/users/bob'))->toBeTrue() + ->and(QuoteService::sameUrl('https://remote.example/users/bob/', 'https://remote.example/users/bob'))->toBeTrue(); + }); + + it('treats different paths and non-string input as not equal', function () { + expect(QuoteService::sameUrl('https://remote.example/users/bob', 'https://remote.example/users/carol'))->toBeFalse() + ->and(QuoteService::sameUrl(null, 'https://remote.example/users/bob'))->toBeFalse() + ->and(QuoteService::sameUrl('not-a-url', 'also-not'))->toBeFalse(); + }); +}); + +describe('privacy settings guards', function () { + it('ignores an invalid can_quote value', function () { + $user = quoteLocalUser(); + + $this->actingAs($user->fresh()) + ->post('/settings/privacy', ['can_quote' => 'not-a-policy']) + ->assertRedirect(); + + // The stored setting is unchanged (still the default) and the account + // policy stays everyone. + QuoteService::forgetPolicy($user->profile->id); + expect($user->settings()->first()->can_quote)->not->toBe('not-a-policy') + ->and(QuoteService::accountPolicy($user->profile->fresh()))->toBe(QuoteService::POLICY_EVERYONE); + }); +});